diff --git a/.github/workflows/docker-ghcr-custom.yml b/.github/workflows/docker-ghcr-custom.yml new file mode 100644 index 000000000000..b4dc27d0ded5 --- /dev/null +++ b/.github/workflows/docker-ghcr-custom.yml @@ -0,0 +1,155 @@ +name: Publish custom image to GHCR + +on: + push: + tags: + - 'v*' + workflow_dispatch: + inputs: + image_tag: + description: 'Docker image tag only (e.g. v1.0.0-custom), not a git ref' + required: true + type: string + +env: + REGISTRY: ghcr.io + +permissions: + contents: read + packages: write + +jobs: + prepare: + name: Prepare build metadata + runs-on: ubuntu-latest + outputs: + image_name: ${{ steps.meta.outputs.image_name }} + tag: ${{ steps.meta.outputs.tag }} + steps: + - id: meta + run: | + echo "image_name=$(echo '${{ github.repository }}' | tr '[:upper:]' '[:lower:]')" >> "$GITHUB_OUTPUT" + if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then + TAG="${{ github.event.inputs.image_tag }}" + else + TAG=${GITHUB_REF#refs/tags/} + fi + echo "tag=${TAG}" >> "$GITHUB_OUTPUT" + + build_single_arch: + name: Build & push (${{ matrix.arch }}) + needs: [prepare] + strategy: + fail-fast: false + matrix: + include: + - arch: amd64 + platform: linux/amd64 + runner: ubuntu-latest + - arch: arm64 + platform: linux/arm64 + runner: ubuntu-24.04-arm + runs-on: ${{ matrix.runner }} + outputs: + tag: ${{ needs.prepare.outputs.tag }} + + permissions: + contents: read + packages: write + + steps: + - name: Check out + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Write VERSION + run: | + TAG="${{ needs.prepare.outputs.tag }}" + echo "TAG=${TAG}" >> "$GITHUB_ENV" + echo "${TAG}" > VERSION + echo "Building tag: ${TAG} for ${{ matrix.arch }}" + echo "Image: ${{ env.REGISTRY }}/${{ needs.prepare.outputs.image_name }}" + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Log in to GHCR + uses: docker/login-action@v3 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Extract metadata (labels) + id: meta + uses: docker/metadata-action@v5 + with: + images: ${{ env.REGISTRY }}/${{ needs.prepare.outputs.image_name }} + + - name: Build & push + id: build + uses: docker/build-push-action@v6 + with: + context: . + platforms: ${{ matrix.platform }} + push: true + tags: | + ${{ env.REGISTRY }}/${{ needs.prepare.outputs.image_name }}:${{ env.TAG }}-${{ matrix.arch }} + ${{ env.REGISTRY }}/${{ needs.prepare.outputs.image_name }}:latest-${{ matrix.arch }} + labels: ${{ steps.meta.outputs.labels }} + cache-from: type=gha + cache-to: type=gha,mode=max + + - name: Image summary + run: | + echo "### Docker Image (${{ matrix.arch }})" >> "$GITHUB_STEP_SUMMARY" + echo '```' >> "$GITHUB_STEP_SUMMARY" + echo "${{ env.REGISTRY }}/${{ needs.prepare.outputs.image_name }}:${TAG}-${{ matrix.arch }}" >> "$GITHUB_STEP_SUMMARY" + echo "${{ steps.build.outputs.digest }}" >> "$GITHUB_STEP_SUMMARY" + echo '```' >> "$GITHUB_STEP_SUMMARY" + + create_manifests: + name: Create multi-arch manifests + needs: [prepare, build_single_arch] + runs-on: ubuntu-latest + + permissions: + contents: read + packages: write + + steps: + - name: Set version + run: | + echo "TAG=${{ needs.prepare.outputs.tag }}" >> "$GITHUB_ENV" + echo "IMAGE_NAME=${{ needs.prepare.outputs.image_name }}" >> "$GITHUB_ENV" + + - name: Log in to GHCR + uses: docker/login-action@v3 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Create & push manifest (version) + run: | + docker buildx imagetools create \ + -t "${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${TAG}" \ + "${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${TAG}-amd64" \ + "${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${TAG}-arm64" + + - name: Create & push manifest (latest) + run: | + docker buildx imagetools create \ + -t "${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest" \ + "${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest-amd64" \ + "${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest-arm64" + + - name: Manifest summary + run: | + echo "### Multi-arch Manifest" >> "$GITHUB_STEP_SUMMARY" + echo '```' >> "$GITHUB_STEP_SUMMARY" + echo "${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${TAG}" >> "$GITHUB_STEP_SUMMARY" + echo "${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest" >> "$GITHUB_STEP_SUMMARY" + docker buildx imagetools inspect "${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${TAG}" >> "$GITHUB_STEP_SUMMARY" + echo '```' >> "$GITHUB_STEP_SUMMARY" diff --git a/.gitignore b/.gitignore index c3afceb021f1..4fc4a5d7ccfa 100644 --- a/.gitignore +++ b/.gitignore @@ -41,3 +41,5 @@ skills-lock.json .local-tests/ service/relayconvert/chat_responses_live_local_test.go service/openaicompat/chat_responses_live_local_test.go +.superpowers +docs \ No newline at end of file diff --git a/controller/extensions_availability.go b/controller/extensions_availability.go new file mode 100644 index 000000000000..935f59d6ef68 --- /dev/null +++ b/controller/extensions_availability.go @@ -0,0 +1,87 @@ +package controller + +import ( + "net/http" + "sort" + + "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/model" + "github.com/QuantumNous/new-api/service" + "github.com/QuantumNous/new-api/setting/console_setting" + "github.com/QuantumNous/new-api/setting/ratio_setting" + + "github.com/gin-gonic/gin" +) + +type extensionsAvailabilityGroup struct { + Group string `json:"group"` + Records []model.GroupAvailabilityRecord `json:"records"` + SuccessRate float64 `json:"success_rate"` + AvgUseTime float64 `json:"avg_use_time"` + Status string `json:"status"` + Total int `json:"total"` + SuccessCount int `json:"success_count"` +} + +func GetExtensionsAvailability(c *gin.Context) { + isAdmin := c.GetInt("role") >= common.RoleAdminUser + if !console_setting.IsAvailabilityMonitorVisible(isAdmin) { + c.JSON(http.StatusForbidden, gin.H{ + "success": false, + "message": "availability monitor is not available", + }) + return + } + + userId := c.GetInt("id") + userGroup, _ := model.GetUserGroup(userId, false) + userUsableGroups := service.GetUserUsableGroups(userGroup) + + groupNames := make([]string, 0) + for groupName := range ratio_setting.GetGroupRatioCopy() { + // Match GetUserGroups: only billing groups the user can select (skip "auto"). + if groupName == "auto" { + continue + } + if _, ok := userUsableGroups[groupName]; !ok { + continue + } + groupNames = append(groupNames, groupName) + } + sort.Strings(groupNames) + + groups := make([]extensionsAvailabilityGroup, 0, len(groupNames)) + for _, groupName := range groupNames { + records, err := model.GetRecentGroupAvailabilityLogs(groupName, 100) + if err != nil { + common.ApiError(c, err) + return + } + okCount := 0 + successUseTimeSum := 0 + for _, record := range records { + if record.Ok { + okCount++ + successUseTimeSum += record.UseTime + } + } + successRate, avgUseTime, status := console_setting.SummarizeAvailabilityRecords( + okCount, + len(records), + successUseTimeSum, + ) + groups = append(groups, extensionsAvailabilityGroup{ + Group: groupName, + Records: records, + SuccessRate: successRate, + AvgUseTime: avgUseTime, + Status: status, + Total: len(records), + SuccessCount: okCount, + }) + } + + common.ApiSuccess(c, gin.H{ + "groups": groups, + }) +} diff --git a/controller/lottery.go b/controller/lottery.go new file mode 100644 index 000000000000..6b6530e7a28c --- /dev/null +++ b/controller/lottery.go @@ -0,0 +1,83 @@ +package controller + +import ( + "fmt" + "net/http" + + "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/logger" + "github.com/QuantumNous/new-api/model" + "github.com/QuantumNous/new-api/setting/operation_setting" + "github.com/gin-gonic/gin" +) + +// GetLotteryStatus 获取抽奖状态 +func GetLotteryStatus(c *gin.Context) { + if !operation_setting.IsLotteryEnabled() { + common.ApiErrorMsg(c, "抽奖功能未启用") + return + } + userId := c.GetInt("id") + data, err := model.GetUserLotteryState(userId) + if err != nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": err.Error(), + }) + return + } + c.JSON(http.StatusOK, gin.H{ + "success": true, + "data": data, + }) +} + +type lotteryDrawRequest struct { + BetUSD float64 `json:"bet_usd"` +} + +// DoLottery 执行抽奖 +func DoLottery(c *gin.Context) { + if !operation_setting.IsLotteryEnabled() { + common.ApiErrorMsg(c, "抽奖功能未启用") + return + } + + var req lotteryDrawRequest + if err := c.ShouldBindJSON(&req); err != nil { + req.BetUSD = 0 + } + + userId := c.GetInt("id") + result, err := model.UserLotteryDraw(userId, req.BetUSD, c.ClientIP()) + if err != nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": err.Error(), + }) + return + } + + usdDelta := operation_setting.QuotaToUsd(result.Draw.QuotaDelta) + msg := fmt.Sprintf("老虎机抽奖:%s,额度变化 %s(约 $%.4f)", result.Draw.PrizeName, logger.LogQuota(result.Draw.QuotaDelta), usdDelta) + model.RecordLog(userId, model.LogTypeSystem, msg) + + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "抽奖成功", + "data": gin.H{ + "prize_index": result.Draw.PrizeIndex, + "prize_name": result.Draw.PrizeName, + "quota_delta": result.Draw.QuotaDelta, + "usd_delta": usdDelta, + "bet_quota": result.Draw.BetQuota, + "bet_usd": operation_setting.QuotaToUsd(result.Draw.BetQuota), + "is_thanks": result.Draw.IsThanks, + "is_pity": result.Draw.IsPity, + "is_thursday": result.Draw.IsThursday, + "remaining_pool": result.RemainingPool, + "remaining_pool_usd": operation_setting.QuotaToUsd(result.RemainingPool), + "draw_date": result.Draw.DrawDate, + }, + }) +} diff --git a/controller/misc.go b/controller/misc.go index fb2029878747..e13985e088ca 100644 --- a/controller/misc.go +++ b/controller/misc.go @@ -19,6 +19,7 @@ import ( "github.com/QuantumNous/new-api/setting/operation_setting" "github.com/QuantumNous/new-api/setting/system_setting" + "github.com/gin-contrib/sessions" "github.com/gin-gonic/gin" ) @@ -122,6 +123,7 @@ func GetStatus(c *gin.Context) { "user_agreement_enabled": legalSetting.UserAgreement != "", "privacy_policy_enabled": legalSetting.PrivacyPolicy != "", "checkin_enabled": operation_setting.GetCheckinSetting().Enabled, + "lottery_enabled": operation_setting.IsLotteryEnabled(), } // 根据启用状态注入可选内容 @@ -135,6 +137,16 @@ func GetStatus(c *gin.Context) { data["faq"] = console_setting.GetFAQ() } + isLoggedIn, isAdmin := statusViewerRole(c) + if isLoggedIn { + data["custom_pages"] = console_setting.GetCustomPagesForRole(isAdmin) + data["availability_monitor_visible"] = console_setting.IsAvailabilityMonitorVisible(isAdmin) + data["availability_monitor_refresh_interval"] = console_setting.GetAvailabilityMonitorRefreshInterval() + } else { + data["custom_pages"] = []map[string]interface{}{} + data["availability_monitor_visible"] = false + } + // Add enabled custom OAuth providers customProviders := oauth.GetEnabledCustomProviders() if len(customProviders) > 0 { @@ -171,6 +183,23 @@ func GetStatus(c *gin.Context) { return } +func statusViewerRole(c *gin.Context) (isLoggedIn bool, isAdmin bool) { + session := sessions.Default(c) + if session.Get("id") == nil { + return false, false + } + role := 0 + switch v := session.Get("role").(type) { + case int: + role = v + case int64: + role = int(v) + case float64: + role = int(v) + } + return true, role >= common.RoleAdminUser +} + func GetNotice(c *gin.Context) { common.OptionMapRWMutex.RLock() defer common.OptionMapRWMutex.RUnlock() diff --git a/controller/option.go b/controller/option.go index a97f07b841b7..c10186d5d02f 100644 --- a/controller/option.go +++ b/controller/option.go @@ -81,6 +81,21 @@ func GetOptions(c *gin.Context) { common.OptionMapRWMutex.Lock() for k, v := range common.OptionMap { value := common.Interface2String(v) + // Turnstile Site Key is public (embedded in the frontend widget). + if k == "TurnstileSiteKey" { + options = append(options, &model.Option{Key: k, Value: value}) + continue + } + // Never return the raw Turnstile secret; expose a mask so the admin UI + // can show that a secret is already configured. + if k == "TurnstileSecretKey" { + masked := "" + if value != "" { + masked = "********" + } + options = append(options, &model.Option{Key: k, Value: masked}) + continue + } isSensitiveKey := strings.HasSuffix(k, "Token") || strings.HasSuffix(k, "Secret") || strings.HasSuffix(k, "Key") || @@ -137,6 +152,17 @@ func UpdateOption(c *gin.Context) { default: option.Value = fmt.Sprintf("%v", option.Value) } + // Masked / empty secret means "keep existing" — never wipe TurnstileSecretKey + if option.Key == "TurnstileSecretKey" { + secret := option.Value.(string) + if secret == "" || secret == "********" { + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "", + }) + return + } + } switch option.Key { case "QuotaForInviter", "QuotaForInvitee": if isPositiveOptionValue(option.Value.(string)) && !operation_setting.IsPaymentComplianceConfirmed() { @@ -322,6 +348,33 @@ func UpdateOption(c *gin.Context) { }) return } + case "console_setting.custom_pages": + err = console_setting.ValidateConsoleSettings(option.Value.(string), "CustomPages") + if err != nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": err.Error(), + }) + return + } + case "console_setting.availability_monitor_visibility": + err = console_setting.ValidateAvailabilityMonitorVisibility(option.Value.(string)) + if err != nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": err.Error(), + }) + return + } + case "console_setting.availability_monitor_refresh_interval": + err = console_setting.ValidateAvailabilityMonitorRefreshInterval(option.Value.(string)) + if err != nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": err.Error(), + }) + return + } case "console_setting.uptime_kuma_groups": err = console_setting.ValidateConsoleSettings(option.Value.(string), "UptimeKumaGroups") if err != nil { diff --git a/controller/token.go b/controller/token.go index 836e9b2952ac..47d5f239a83e 100644 --- a/controller/token.go +++ b/controller/token.go @@ -230,6 +230,11 @@ func AddToken(c *gin.Context) { c.JSON(http.StatusOK, gin.H{ "success": true, "message": "", + "data": gin.H{ + "id": cleanToken.Id, + "key": cleanToken.Key, + "name": cleanToken.Name, + }, }) } diff --git a/middleware/turnstile_strict.go b/middleware/turnstile_strict.go new file mode 100644 index 000000000000..102182d76c24 --- /dev/null +++ b/middleware/turnstile_strict.go @@ -0,0 +1,72 @@ +package middleware + +import ( + "encoding/json" + "net/http" + "net/url" + + "github.com/QuantumNous/new-api/common" + "github.com/gin-gonic/gin" +) + +// turnstileVerifyFunc allows tests to mock Cloudflare siteverify. +var turnstileVerifyFunc = defaultTurnstileVerify + +func defaultTurnstileVerify(secret, response, remoteIP string) (bool, error) { + rawRes, err := http.PostForm("https://challenges.cloudflare.com/turnstile/v0/siteverify", url.Values{ + "secret": {secret}, + "response": {response}, + "remoteip": {remoteIP}, + }) + if err != nil { + return false, err + } + defer rawRes.Body.Close() + var res turnstileCheckResponse + if err := json.NewDecoder(rawRes.Body).Decode(&res); err != nil { + return false, err + } + return res.Success, nil +} + +// TurnstileCheckStrict 每次请求都向 Cloudflare 校验,不使用 session 缓存。 +// 全局 Turnstile 关闭时直接放行。 +func TurnstileCheckStrict() gin.HandlerFunc { + return func(c *gin.Context) { + if !common.TurnstileCheckEnabled { + c.Next() + return + } + response := c.Query("turnstile") + if response == "" { + response = c.GetHeader("X-Turnstile-Token") + } + if response == "" { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "Turnstile token 为空", + }) + c.Abort() + return + } + ok, err := turnstileVerifyFunc(common.TurnstileSecretKey, response, c.ClientIP()) + if err != nil { + common.SysLog(err.Error()) + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": err.Error(), + }) + c.Abort() + return + } + if !ok { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "Turnstile 校验失败,请刷新重试!", + }) + c.Abort() + return + } + c.Next() + } +} diff --git a/middleware/turnstile_strict_test.go b/middleware/turnstile_strict_test.go new file mode 100644 index 000000000000..5f23a449636b --- /dev/null +++ b/middleware/turnstile_strict_test.go @@ -0,0 +1,88 @@ +package middleware + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/QuantumNous/new-api/common" + "github.com/gin-gonic/gin" +) + +func TestTurnstileCheckStrictDisabled(t *testing.T) { + gin.SetMode(gin.TestMode) + common.TurnstileCheckEnabled = false + r := gin.New() + r.POST("/x", TurnstileCheckStrict(), func(c *gin.Context) { + c.JSON(200, gin.H{"success": true}) + }) + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/x", nil) + r.ServeHTTP(w, req) + if w.Code != 200 { + t.Fatalf("code %d", w.Code) + } +} + +func TestTurnstileCheckStrictRequiresToken(t *testing.T) { + gin.SetMode(gin.TestMode) + common.TurnstileCheckEnabled = true + common.TurnstileSecretKey = "secret" + r := gin.New() + r.POST("/x", TurnstileCheckStrict(), func(c *gin.Context) { + c.JSON(200, gin.H{"success": true, "ok": true}) + }) + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/x", nil) + r.ServeHTTP(w, req) + if w.Body.String() == "" || w.Code != 200 { + t.Fatalf("unexpected: %s", w.Body.String()) + } + if !contains(w.Body.String(), "Turnstile token 为空") { + t.Fatalf("want empty token error, got %s", w.Body.String()) + } +} + +func TestTurnstileCheckStrictEveryRequest(t *testing.T) { + gin.SetMode(gin.TestMode) + common.TurnstileCheckEnabled = true + common.TurnstileSecretKey = "secret" + calls := 0 + turnstileVerifyFunc = func(secret, response, remoteIP string) (bool, error) { + calls++ + return response == "good", nil + } + t.Cleanup(func() { + turnstileVerifyFunc = defaultTurnstileVerify + common.TurnstileCheckEnabled = false + }) + + r := gin.New() + r.POST("/x", TurnstileCheckStrict(), func(c *gin.Context) { + c.JSON(200, gin.H{"success": true}) + }) + + for i := 0; i < 2; i++ { + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/x?turnstile=good", nil) + r.ServeHTTP(w, req) + if !contains(w.Body.String(), `"success":true`) { + t.Fatalf("request %d failed: %s", i, w.Body.String()) + } + } + if calls != 2 { + t.Fatalf("strict mode should verify every request, calls=%d", calls) + } +} + +func contains(s, sub string) bool { + return len(s) >= len(sub) && (s == sub || len(sub) == 0 || + (func() bool { + for i := 0; i+len(sub) <= len(s); i++ { + if s[i:i+len(sub)] == sub { + return true + } + } + return false + })()) +} diff --git a/model/log.go b/model/log.go index 506bd504b686..17de259fda01 100644 --- a/model/log.go +++ b/model/log.go @@ -762,3 +762,62 @@ func DeleteOldLog(ctx context.Context, targetTimestamp int64, limit int) (int64, return total, nil } + +// GroupAvailabilityRecord is a channel-free projection of recent group logs. +type GroupAvailabilityRecord struct { + CreatedAt int64 `json:"created_at"` + UseTime int `json:"use_time"` + Ok bool `json:"ok"` +} + +// GetRecentGroupAvailabilityLogs returns the latest consume/error logs for a billing group. +// Results are chronological (oldest → newest). No channel fields are loaded. +func GetRecentGroupAvailabilityLogs(group string, limit int) ([]GroupAvailabilityRecord, error) { + if group == "" { + return []GroupAvailabilityRecord{}, nil + } + if limit <= 0 { + limit = 100 + } + if limit > 100 { + limit = 100 + } + + type row struct { + CreatedAt int64 `gorm:"column:created_at"` + UseTime int `gorm:"column:use_time"` + Type int `gorm:"column:type"` + } + + var rows []row + order := "created_at desc, id desc" + if common.UsingLogDatabase(common.DatabaseTypeClickHouse) { + order = clickHouseLogOrder("") + } + + err := LOG_DB.Model(&Log{}). + Select("created_at", "use_time", "type"). + Where("type IN ?", []int{LogTypeConsume, LogTypeError}). + Where(logGroupCol+" = ?", group). + Order(order). + Limit(limit). + Find(&rows).Error + if err != nil { + return nil, err + } + + // Reverse to chronological order for PAST → NOW charts. + for i, j := 0, len(rows)-1; i < j; i, j = i+1, j-1 { + rows[i], rows[j] = rows[j], rows[i] + } + + result := make([]GroupAvailabilityRecord, 0, len(rows)) + for _, item := range rows { + result = append(result, GroupAvailabilityRecord{ + CreatedAt: item.CreatedAt, + UseTime: item.UseTime, + Ok: item.Type == LogTypeConsume, + }) + } + return result, nil +} diff --git a/model/log_availability_test.go b/model/log_availability_test.go new file mode 100644 index 000000000000..06cc7a2d2fe1 --- /dev/null +++ b/model/log_availability_test.go @@ -0,0 +1,35 @@ +package model + +import ( + "reflect" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestGroupAvailabilityRecordJSONWhitelist(t *testing.T) { + t.Parallel() + + typ := reflect.TypeOf(GroupAvailabilityRecord{}) + require.Equal(t, 3, typ.NumField()) + + allowed := map[string]struct{}{ + "created_at": {}, + "use_time": {}, + "ok": {}, + } + forbiddenSubstr := []string{"channel", "token", "username", "request", "other"} + + for i := 0; i < typ.NumField(); i++ { + field := typ.Field(i) + jsonTag := field.Tag.Get("json") + require.NotEmpty(t, jsonTag) + assert.Contains(t, allowed, jsonTag) + lowerName := field.Name + for _, bad := range forbiddenSubstr { + assert.NotContains(t, lowerName, bad) + assert.NotContains(t, jsonTag, bad) + } + } +} diff --git a/model/lottery.go b/model/lottery.go new file mode 100644 index 000000000000..a56067d2cf4f --- /dev/null +++ b/model/lottery.go @@ -0,0 +1,649 @@ +package model + +import ( + "errors" + "fmt" + "math/rand" + "time" + + "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/setting/operation_setting" + "gorm.io/gorm" +) + +// LotteryDraw 用户抽奖记录(每用户每天一条) +type LotteryDraw struct { + Id int `json:"id" gorm:"primaryKey;autoIncrement"` + UserId int `json:"user_id" gorm:"not null;uniqueIndex:idx_lottery_user_date"` + DrawDate string `json:"draw_date" gorm:"type:varchar(10);not null;uniqueIndex:idx_lottery_user_date"` + BetQuota int `json:"bet_quota" gorm:"not null;default:0"` + PrizeName string `json:"prize_name" gorm:"type:varchar(128);not null"` + PrizeIndex int `json:"prize_index" gorm:"not null"` + QuotaDelta int `json:"quota_delta" gorm:"not null"` + IsThanks bool `json:"is_thanks" gorm:"default:false"` + IsPity bool `json:"is_pity" gorm:"default:false"` + IsThursday bool `json:"is_thursday" gorm:"default:false"` + ClientIP string `json:"client_ip" gorm:"type:varchar(64);index"` + CreatedAt int64 `json:"created_at" gorm:"bigint"` +} + +func (LotteryDraw) TableName() string { + return "lottery_draws" +} + +// LotteryDailyPool 每日奖池 +type LotteryDailyPool struct { + PoolDate string `json:"pool_date" gorm:"primaryKey;type:varchar(10)"` + TotalQuota int `json:"total_quota" gorm:"not null"` + RemainingQuota int `json:"remaining_quota" gorm:"not null"` + UpdatedAt int64 `json:"updated_at" gorm:"bigint"` +} + +func (LotteryDailyPool) TableName() string { + return "lottery_daily_pools" +} + +// LotteryUserState 用户保底状态 +type LotteryUserState struct { + UserId int `json:"user_id" gorm:"primaryKey"` + ThanksStreak int `json:"thanks_streak" gorm:"not null;default:0"` +} + +func (LotteryUserState) TableName() string { + return "lottery_user_states" +} + +// LotteryResult 抽奖结果(对外) +type LotteryResult struct { + Draw *LotteryDraw `json:"draw"` + RemainingPool int `json:"remaining_pool"` +} + +var ( + lotteryNowFunc = time.Now + lotteryRandIntn = rand.Intn +) + +func lotteryToday() string { + return lotteryNowFunc().Format("2006-01-02") +} + +func lotteryIsThursday() bool { + return lotteryNowFunc().Weekday() == time.Thursday +} + +// userHasRedeemedCode 是否至少成功兑换过一次兑换码。 +// 已使用兑换码可能被定时任务软删除,因此必须 Unscoped;并回退查充值日志兼容更早数据。 +func userHasRedeemedCode(userId int) (bool, error) { + var id int + err := DB.Unscoped().Model(&Redemption{}). + Select("id"). + Where("used_user_id = ? AND status = ?", userId, common.RedemptionCodeStatusUsed). + Limit(1). + Take(&id).Error + if err == nil { + return true, nil + } + if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { + return false, err + } + + // 回退:兑换成功会写「通过兑换码充值」的 topup 日志 + var logId int + err = DB.Model(&Log{}). + Select("id"). + Where("user_id = ? AND type = ? AND content LIKE ?", userId, LogTypeTopup, "%通过兑换码充值%"). + Limit(1). + Take(&logId).Error + if errors.Is(err, gorm.ErrRecordNotFound) { + return false, nil + } + if err != nil { + return false, err + } + return true, nil +} + +// checkLotteryRedemptionGate 平时需兑换过码;疯狂星期四 / 管理员 / 关闭开关时放行 +func checkLotteryRedemptionGate(userId int, requireRedemption bool, isThursday bool, isAdmin bool) (meets bool, err error) { + if !requireRedemption || isThursday || isAdmin { + return true, nil + } + return userHasRedeemedCode(userId) +} + +// GetUserLotteryState 获取用户抽奖状态(供 GET API) +func GetUserLotteryState(userId int) (map[string]interface{}, error) { + setting := operation_setting.GetLotterySetting() + if !setting.Enabled { + return nil, errors.New("抽奖功能未启用") + } + if err := operation_setting.ValidateLotterySetting(setting); err != nil { + return nil, fmt.Errorf("抽奖配置无效: %w", err) + } + + today := lotteryToday() + isThursday := lotteryIsThursday() + effectivePoolUSD := operation_setting.EffectiveDailyPoolUSD(setting.DailyPoolUSD, isThursday) + effectivePoolQuota := operation_setting.UsdToQuota(effectivePoolUSD) + + if _, err := ensureLotteryDailyPool(today, effectivePoolQuota); err != nil { + return nil, err + } + + var todayDraw *LotteryDraw + var draw LotteryDraw + err := DB.Where("user_id = ? AND draw_date = ?", userId, today).First(&draw).Error + if err == nil { + todayDraw = &draw + } else if !errors.Is(err, gorm.ErrRecordNotFound) { + return nil, err + } + + streak, _ := getLotteryThanksStreak(userId) + userQuota, _ := GetUserQuota(userId, false) + isAdmin := IsAdmin(userId) + meetsRedemption, err := checkLotteryRedemptionGate(userId, setting.RequireRedemption, isThursday, isAdmin) + if err != nil { + return nil, err + } + + freePrizes := make([]map[string]interface{}, 0, len(setting.FreePrizes)) + for _, p := range setting.FreePrizes { + usd := operation_setting.EffectiveFreeUSD(p.Usd, isThursday) + freePrizes = append(freePrizes, map[string]interface{}{ + "name": p.Name, + "usd": usd, + "weight": p.Weight, + "is_thanks": p.IsThanks, + }) + } + betPrizes := make([]map[string]interface{}, 0, len(setting.BetPrizes)) + for _, p := range setting.BetPrizes { + betPrizes = append(betPrizes, map[string]interface{}{ + "name": p.Name, + "multiplier": p.Multiplier, + "weight": p.Weight, + "is_thanks": p.IsThanks, + }) + } + + todayDrawView := interface{}(nil) + if todayDraw != nil { + todayDrawView = map[string]interface{}{ + "prize_name": todayDraw.PrizeName, + "prize_index": todayDraw.PrizeIndex, + "quota_delta": todayDraw.QuotaDelta, + "usd_delta": operation_setting.QuotaToUsd(todayDraw.QuotaDelta), + "bet_quota": todayDraw.BetQuota, + "bet_usd": operation_setting.QuotaToUsd(todayDraw.BetQuota), + "is_thanks": todayDraw.IsThanks, + "is_pity": todayDraw.IsPity, + "is_thursday": todayDraw.IsThursday, + "draw_date": todayDraw.DrawDate, + } + } + + // 管理员不限制每日次数,方便自测;未兑换过码(非周四)不可抽 + canDraw := (todayDraw == nil || isAdmin) && meetsRedemption + + // 用户侧只返回展示奖池,不暴露真实限额与剩余 + displayPoolUSD := operation_setting.ResolvedDisplayDailyPoolUSD(setting) + effectiveDisplayPoolUSD := operation_setting.EffectiveDailyPoolUSD(displayPoolUSD, isThursday) + + return map[string]interface{}{ + "enabled": true, + "can_draw": canDraw, + "meets_redemption_requirement": meetsRedemption, + "require_redemption": setting.RequireRedemption, + "is_crazy_thursday": isThursday, + "display_daily_pool_usd": displayPoolUSD, + "effective_display_daily_pool_usd": effectiveDisplayPoolUSD, + "min_bet_usd": setting.MinBetUSD, + "max_bet_usd": setting.MaxBetUSD, + "user_quota": userQuota, + "user_usd": operation_setting.QuotaToUsd(userQuota), + "quota_per_unit": common.QuotaPerUnit, + "thanks_streak": streak, + "pity_threshold": 2, + "free_prizes": freePrizes, + "bet_prizes": betPrizes, + "today_draw": todayDrawView, + }, nil +} + +// UserLotteryDraw 执行抽奖;betUSD 为投入美元,0 表示免费模式 +func UserLotteryDraw(userId int, betUSD float64, clientIP string) (*LotteryResult, error) { + setting := operation_setting.GetLotterySetting() + if !setting.Enabled { + return nil, errors.New("抽奖功能未启用") + } + if err := operation_setting.ValidateLotterySetting(setting); err != nil { + return nil, fmt.Errorf("抽奖配置无效: %w", err) + } + if betUSD < 0 { + return nil, errors.New("投入金额无效") + } + + today := lotteryToday() + isThursday := lotteryIsThursday() + isAdmin := IsAdmin(userId) + + meetsRedemption, err := checkLotteryRedemptionGate(userId, setting.RequireRedemption, isThursday, isAdmin) + if err != nil { + return nil, err + } + if !meetsRedemption { + return nil, errors.New("请先使用兑换码充值后再参与抽奖(疯狂星期四无需兑换)") + } + + var existing int64 + if err := DB.Model(&LotteryDraw{}).Where("user_id = ? AND draw_date = ?", userId, today).Count(&existing).Error; err != nil { + return nil, err + } + if existing > 0 { + if !isAdmin { + return nil, errors.New("今日已抽奖") + } + // 管理员可重复抽:删除当日旧记录,避免唯一索引冲突 + if err := DB.Where("user_id = ? AND draw_date = ?", userId, today).Delete(&LotteryDraw{}).Error; err != nil { + return nil, err + } + } + + if !isAdmin && setting.MaxDrawsPerIPPerDay > 0 && clientIP != "" { + var ipCount int64 + if err := DB.Model(&LotteryDraw{}). + Where("client_ip = ? AND draw_date = ?", clientIP, today). + Count(&ipCount).Error; err != nil { + return nil, err + } + if int(ipCount) >= setting.MaxDrawsPerIPPerDay { + return nil, errors.New("当前网络今日抽奖次数已达上限") + } + } + + userQuota, err := GetUserQuota(userId, true) + if err != nil { + return nil, err + } + userUSD := operation_setting.QuotaToUsd(userQuota) + + betQuota := 0 + if betUSD > 0 { + if betUSD < setting.MinBetUSD || betUSD > setting.MaxBetUSD { + return nil, errors.New("投入金额超出允许范围") + } + if betUSD > userUSD+1e-9 { + return nil, errors.New("投入金额不能超过当前余额") + } + betQuota = operation_setting.UsdToQuota(betUSD) + if betQuota > userQuota { + return nil, errors.New("投入金额不能超过当前余额") + } + } + + streak, err := getLotteryThanksStreak(userId) + if err != nil { + return nil, err + } + forcePity := streak >= 2 + + prizeIndex, prize, quotaDelta, isThanks, isPity := pickLotteryPrize(setting, betQuota, isThursday, forcePity) + + effectivePoolQuota := operation_setting.EffectiveDailyPoolQuota(setting.DailyPoolUSD, isThursday) + + // 奖池不足时降级 + prizeIndex, prize, quotaDelta, isThanks, isPity = adjustPrizeForPool(setting, betQuota, isThursday, prizeIndex, prize, quotaDelta, isThanks, isPity, effectivePoolQuota) + + // 负额度时确保余额足够(投入模式) + if userQuota+quotaDelta < 0 { + return nil, errors.New("余额不足,无法承担本次惩罚结果") + } + + draw := &LotteryDraw{ + UserId: userId, + DrawDate: today, + BetQuota: betQuota, + PrizeName: prize.Name, + PrizeIndex: prizeIndex, + QuotaDelta: quotaDelta, + IsThanks: isThanks, + IsPity: isPity, + IsThursday: isThursday, + ClientIP: clientIP, + CreatedAt: lotteryNowFunc().Unix(), + } + + var remaining int + if common.UsingMainDatabase(common.DatabaseTypeSQLite) { + remaining, err = userLotteryWithoutTransaction(draw, userId, quotaDelta, effectivePoolQuota, isThanks) + } else { + remaining, err = userLotteryWithTransaction(draw, userId, quotaDelta, effectivePoolQuota, isThanks) + } + if err != nil { + return nil, err + } + + return &LotteryResult{Draw: draw, RemainingPool: remaining}, nil +} + +func pickLotteryPrize( + setting *operation_setting.LotterySetting, + betQuota int, + isThursday bool, + forcePity bool, +) (index int, prize operation_setting.LotteryPrize, delta int, isThanks bool, isPity bool) { + prizes := setting.FreePrizes + betMode := betQuota > 0 + if betMode { + prizes = setting.BetPrizes + } + + if forcePity { + idx, p, ok := smallestPositivePrize(prizes, betMode) + if ok { + delta = computePrizeDelta(p, betQuota, isThursday, betMode) + return idx, p, delta, false, true + } + } + + index = weightedPick(prizes) + prize = prizes[index] + isThanks = prize.IsThanks + delta = computePrizeDelta(prize, betQuota, isThursday, betMode) + return index, prize, delta, isThanks, false +} + +func computePrizeDelta(prize operation_setting.LotteryPrize, betQuota int, isThursday bool, betMode bool) int { + if betMode { + return operation_setting.RoundBetDelta(betQuota, prize.Multiplier) + } + return operation_setting.EffectiveFreeQuota(prize.Usd, isThursday) +} + +func smallestPositivePrize(prizes []operation_setting.LotteryPrize, betMode bool) (int, operation_setting.LotteryPrize, bool) { + bestIdx := -1 + var best operation_setting.LotteryPrize + for i, p := range prizes { + if betMode { + if p.Multiplier <= 0 { + continue + } + if bestIdx < 0 || p.Multiplier < best.Multiplier { + bestIdx = i + best = p + } + continue + } + if p.Usd <= 0 { + continue + } + if bestIdx < 0 || p.Usd < best.Usd { + bestIdx = i + best = p + } + } + if bestIdx < 0 { + return 0, operation_setting.LotteryPrize{}, false + } + return bestIdx, best, true +} + +func weightedPick(prizes []operation_setting.LotteryPrize) int { + total := 0 + for _, p := range prizes { + total += p.Weight + } + if total <= 0 { + return 0 + } + r := lotteryRandIntn(total) + cum := 0 + for i, p := range prizes { + cum += p.Weight + if r < cum { + return i + } + } + return len(prizes) - 1 +} + +func adjustPrizeForPool( + setting *operation_setting.LotterySetting, + betQuota int, + isThursday bool, + index int, + prize operation_setting.LotteryPrize, + delta int, + isThanks bool, + isPity bool, + poolTotalHint int, +) (int, operation_setting.LotteryPrize, int, bool, bool) { + _ = poolTotalHint + if delta <= 0 { + return index, prize, delta, isThanks, isPity + } + + today := lotteryToday() + pool, err := ensureLotteryDailyPool(today, operation_setting.EffectiveDailyPoolQuota(setting.DailyPoolUSD, isThursday)) + if err != nil { + return index, prize, delta, isThanks, isPity + } + if pool.RemainingQuota >= delta { + return index, prize, delta, isThanks, isPity + } + + betMode := betQuota > 0 + prizes := setting.FreePrizes + if betMode { + prizes = setting.BetPrizes + } + + // 找奖池能覆盖的最大正奖 + bestIdx := -1 + var best operation_setting.LotteryPrize + bestDelta := 0 + for i, p := range prizes { + d := computePrizeDelta(p, betQuota, isThursday, betMode) + if d <= 0 || d > pool.RemainingQuota { + continue + } + if bestIdx < 0 || d > bestDelta { + bestIdx = i + best = p + bestDelta = d + } + } + if bestIdx >= 0 { + return bestIdx, best, bestDelta, false, isPity + } + + // 强制谢谢惠顾;若本是保底则保留 isPity,以便 streak 不清零 + for i, p := range prizes { + if p.IsThanks { + return i, p, 0, true, isPity + } + } + return 0, prizes[0], 0, true, isPity +} + +func ensureLotteryDailyPool(date string, total int) (*LotteryDailyPool, error) { + var pool LotteryDailyPool + err := DB.Where("pool_date = ?", date).First(&pool).Error + if err == nil { + return &pool, nil + } + if !errors.Is(err, gorm.ErrRecordNotFound) { + return nil, err + } + pool = LotteryDailyPool{ + PoolDate: date, + TotalQuota: total, + RemainingQuota: total, + UpdatedAt: lotteryNowFunc().Unix(), + } + if err := DB.Create(&pool).Error; err != nil { + // 并发创建时再读一次 + var again LotteryDailyPool + if e := DB.Where("pool_date = ?", date).First(&again).Error; e == nil { + return &again, nil + } + return nil, err + } + return &pool, nil +} + +func getLotteryThanksStreak(userId int) (int, error) { + var state LotteryUserState + err := DB.Where("user_id = ?", userId).First(&state).Error + if errors.Is(err, gorm.ErrRecordNotFound) { + return 0, nil + } + if err != nil { + return 0, err + } + return state.ThanksStreak, nil +} + +func userLotteryWithTransaction(draw *LotteryDraw, userId, quotaDelta, effectivePool int, isThanks bool) (int, error) { + remaining := 0 + err := DB.Transaction(func(tx *gorm.DB) error { + today := draw.DrawDate + var pool LotteryDailyPool + if err := lockForUpdate(tx).Where("pool_date = ?", today).First(&pool).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + pool = LotteryDailyPool{ + PoolDate: today, + TotalQuota: effectivePool, + RemainingQuota: effectivePool, + UpdatedAt: lotteryNowFunc().Unix(), + } + if err := tx.Create(&pool).Error; err != nil { + return errors.New("初始化奖池失败") + } + if err := lockForUpdate(tx).Where("pool_date = ?", today).First(&pool).Error; err != nil { + return err + } + } else { + return err + } + } + + // 再次按锁后剩余调整(并发) + if quotaDelta > 0 && pool.RemainingQuota < quotaDelta { + return errors.New("奖池不足,请稍后重试") + } + + if err := tx.Create(draw).Error; err != nil { + return errors.New("抽奖失败,请稍后重试") + } + + var user User + if err := lockForUpdate(tx).Where("id = ?", userId).First(&user).Error; err != nil { + return errors.New("用户不存在") + } + if user.Quota+quotaDelta < 0 { + return errors.New("余额不足,无法承担本次结果") + } + if err := tx.Model(&User{}).Where("id = ?", userId). + Update("quota", gorm.Expr("quota + ?", quotaDelta)).Error; err != nil { + return errors.New("更新额度失败") + } + + newRemaining := pool.RemainingQuota + if quotaDelta > 0 { + newRemaining -= quotaDelta + } else if quotaDelta < 0 { + newRemaining += -quotaDelta + } + if err := tx.Model(&LotteryDailyPool{}).Where("pool_date = ?", today).Updates(map[string]interface{}{ + "remaining_quota": newRemaining, + "updated_at": lotteryNowFunc().Unix(), + }).Error; err != nil { + return errors.New("更新奖池失败") + } + remaining = newRemaining + + return applyThanksStreakTx(tx, userId, draw) + }) + if err != nil { + return 0, err + } + if quotaDelta != 0 { + go func() { + _ = cacheIncrUserQuota(userId, int64(quotaDelta)) + }() + } + return remaining, nil +} + +func userLotteryWithoutTransaction(draw *LotteryDraw, userId, quotaDelta, effectivePool int, isThanks bool) (int, error) { + _ = isThanks + today := draw.DrawDate + pool, err := ensureLotteryDailyPool(today, effectivePool) + if err != nil { + return 0, err + } + if quotaDelta > 0 && pool.RemainingQuota < quotaDelta { + return 0, errors.New("奖池不足,请稍后重试") + } + + if err := DB.Create(draw).Error; err != nil { + return 0, errors.New("抽奖失败,请稍后重试") + } + + if quotaDelta > 0 { + if err := IncreaseUserQuota(userId, quotaDelta, true); err != nil { + DB.Delete(draw) + return 0, errors.New("更新额度失败") + } + } else if quotaDelta < 0 { + if err := DecreaseUserQuota(userId, -quotaDelta, true); err != nil { + DB.Delete(draw) + return 0, errors.New("更新额度失败") + } + } + + newRemaining := pool.RemainingQuota + if quotaDelta > 0 { + newRemaining -= quotaDelta + } else if quotaDelta < 0 { + newRemaining += -quotaDelta + } + if err := DB.Model(&LotteryDailyPool{}).Where("pool_date = ?", today).Updates(map[string]interface{}{ + "remaining_quota": newRemaining, + "updated_at": lotteryNowFunc().Unix(), + }).Error; err != nil { + return 0, errors.New("更新奖池失败") + } + + _ = applyThanksStreakTx(DB, userId, draw) + return newRemaining, nil +} + +// applyThanksStreakTx 更新保底计数:谢谢惠顾递增;中奖清零;保底因奖池发 0 时保持 +func applyThanksStreakTx(tx *gorm.DB, userId int, draw *LotteryDraw) error { + if draw.IsPity && draw.QuotaDelta == 0 { + return nil + } + + var state LotteryUserState + err := tx.Where("user_id = ?", userId).First(&state).Error + if errors.Is(err, gorm.ErrRecordNotFound) { + state = LotteryUserState{UserId: userId, ThanksStreak: 0} + if draw.IsThanks { + state.ThanksStreak = 1 + } + return tx.Create(&state).Error + } + if err != nil { + return err + } + if draw.IsThanks { + state.ThanksStreak++ + } else { + state.ThanksStreak = 0 + } + return tx.Save(&state).Error +} diff --git a/model/lottery_pick_test.go b/model/lottery_pick_test.go new file mode 100644 index 000000000000..05da277e4671 --- /dev/null +++ b/model/lottery_pick_test.go @@ -0,0 +1,61 @@ +package model + +import ( + "testing" + + "github.com/QuantumNous/new-api/setting/operation_setting" +) + +func TestWeightedPickRespectsWeights(t *testing.T) { + prizes := []operation_setting.LotteryPrize{ + {Name: "a", Weight: 1}, + {Name: "b", Weight: 99}, + } + lotteryRandIntn = func(n int) int { return 0 } + if got := weightedPick(prizes); got != 0 { + t.Fatalf("want 0, got %d", got) + } + lotteryRandIntn = func(n int) int { return 1 } + if got := weightedPick(prizes); got != 1 { + t.Fatalf("want 1, got %d", got) + } +} + +func TestPickLotteryPrizePity(t *testing.T) { + setting := &operation_setting.LotterySetting{ + FreePrizes: []operation_setting.LotteryPrize{ + {Name: "thanks", Usd: 0, Weight: 100, IsThanks: true}, + {Name: "small", Usd: 0.01, Weight: 1}, + {Name: "big", Usd: 1, Weight: 1}, + }, + BetPrizes: []operation_setting.LotteryPrize{ + {Name: "thanks", Multiplier: 0, Weight: 100, IsThanks: true}, + {Name: "small", Multiplier: 0.2, Weight: 1}, + {Name: "big", Multiplier: 2, Weight: 1}, + }, + } + idx, prize, delta, isThanks, isPity := pickLotteryPrize(setting, 0, false, true) + wantDelta := operation_setting.UsdToQuota(0.01) + if !isPity || isThanks || prize.Usd != 0.01 || delta != wantDelta || idx != 1 { + t.Fatalf("pity free failed: idx=%d prize=%+v delta=%d thanks=%v pity=%v", idx, prize, delta, isThanks, isPity) + } + + idx, prize, delta, isThanks, isPity = pickLotteryPrize(setting, 1000, false, true) + if !isPity || isThanks || prize.Multiplier != 0.2 || delta != 200 { + t.Fatalf("pity bet failed: idx=%d prize=%+v delta=%d thanks=%v pity=%v", idx, prize, delta, isThanks, isPity) + } +} + +func TestPickLotteryPrizeThursdayFreeAmount(t *testing.T) { + setting := &operation_setting.LotterySetting{ + FreePrizes: []operation_setting.LotteryPrize{ + {Name: "mid", Usd: 0.5, Weight: 1}, + }, + } + lotteryRandIntn = func(n int) int { return 0 } + _, _, delta, _, _ := pickLotteryPrize(setting, 0, true, false) + want := operation_setting.UsdToQuota(1) + if delta != want { + t.Fatalf("thursday free amount want %d, got %d", want, delta) + } +} diff --git a/model/main.go b/model/main.go index 76f98a59c307..049136f027d2 100644 --- a/model/main.go +++ b/model/main.go @@ -288,6 +288,9 @@ func migrateDB() error { &TwoFA{}, &TwoFABackupCode{}, &Checkin{}, + &LotteryDraw{}, + &LotteryDailyPool{}, + &LotteryUserState{}, &SubscriptionOrder{}, &UserSubscription{}, &SubscriptionPreConsumeRecord{}, diff --git a/model/pricing.go b/model/pricing.go index 440e1e0999b9..e04f89ab36ee 100644 --- a/model/pricing.go +++ b/model/pricing.go @@ -32,6 +32,11 @@ type Pricing struct { AudioRatio *float64 `json:"audio_ratio,omitempty"` AudioCompletionRatio *float64 `json:"audio_completion_ratio,omitempty"` EnableGroup []string `json:"enable_groups"` + // EnableGroupsByEndpoint maps a channel's primary endpoint type to the + // groups where that model is actually served via that endpoint. Unlike + // EnableGroup (union across all channels), this preserves endpoint×group + // pairing so clients can filter groups per protocol (e.g. Anthropic vs OpenAI). + EnableGroupsByEndpoint map[string][]string `json:"enable_groups_by_endpoint,omitempty"` SupportedEndpointTypes []constant.EndpointType `json:"supported_endpoint_types"` BillingMode string `json:"billing_mode,omitempty"` BillingExpr string `json:"billing_expr,omitempty"` @@ -117,6 +122,21 @@ func getPricingEndpointTypesForAbility(ability AbilityWithChannel, advancedCusto return common.GetEndpointTypesByChannelType(ability.ChannelType, ability.Model) } +// primaryPricingEndpointType returns the channel's native protocol endpoint, +// skipping image-generation which may be prepended as a capability flag. +func primaryPricingEndpointType(endpoints []constant.EndpointType) constant.EndpointType { + for _, et := range endpoints { + if et == constant.EndpointTypeImageGeneration { + continue + } + return et + } + if len(endpoints) > 0 { + return endpoints[0] + } + return "" +} + // loadPricingAdvancedCustomConfigs runs inside updatePricing while // updatePricingLock is held, and nests channelSyncLock.RLock. This defines the // global lock order updatePricingLock -> channelSyncLock: any code path holding @@ -259,6 +279,8 @@ func updatePricing() { } modelGroupsMap := make(map[string]*types.Set[string]) + // model -> primary endpoint -> groups + modelGroupsByEndpoint := make(map[string]map[string]*types.Set[string]) for _, ability := range enableAbilities { groups, ok := modelGroupsMap[ability.Model] @@ -273,7 +295,7 @@ func updatePricing() { modelSupportEndpointsStr := make(map[string][]string) advancedCustomConfigs := loadPricingAdvancedCustomConfigs(enableAbilities) - // 先根据已有能力填充原生端点 + // 先根据已有能力填充原生端点,并按渠道主端点记录分组 for _, ability := range enableAbilities { endpoints := modelSupportEndpointsStr[ability.Model] channelTypes := getPricingEndpointTypesForAbility(ability, advancedCustomConfigs) @@ -283,6 +305,23 @@ func updatePricing() { } } modelSupportEndpointsStr[ability.Model] = endpoints + + primary := primaryPricingEndpointType(channelTypes) + if primary == "" || ability.Group == "" { + continue + } + byEndpoint, ok := modelGroupsByEndpoint[ability.Model] + if !ok { + byEndpoint = make(map[string]*types.Set[string]) + modelGroupsByEndpoint[ability.Model] = byEndpoint + } + primaryKey := string(primary) + endpointGroups, ok := byEndpoint[primaryKey] + if !ok { + endpointGroups = types.NewSet[string]() + byEndpoint[primaryKey] = endpointGroups + } + endpointGroups.Add(ability.Group) } // 再补充模型自定义端点:若配置有效则追加到已有推断,不再裁剪渠道真实能力 @@ -356,9 +395,17 @@ func updatePricing() { pricingMap = make([]Pricing, 0) for model, groups := range modelGroupsMap { + var enableGroupsByEndpoint map[string][]string + if byEndpoint := modelGroupsByEndpoint[model]; len(byEndpoint) > 0 { + enableGroupsByEndpoint = make(map[string][]string, len(byEndpoint)) + for endpoint, endpointGroups := range byEndpoint { + enableGroupsByEndpoint[endpoint] = endpointGroups.Items() + } + } pricing := Pricing{ ModelName: model, EnableGroup: groups.Items(), + EnableGroupsByEndpoint: enableGroupsByEndpoint, SupportedEndpointTypes: modelSupportEndpointTypes[model], } diff --git a/model/redemption.go b/model/redemption.go index 23985ef474b7..96fc114f9cbc 100644 --- a/model/redemption.go +++ b/model/redemption.go @@ -1,9 +1,11 @@ package model import ( + "encoding/hex" "errors" "fmt" "strconv" + "strings" "github.com/QuantumNous/new-api/common" "github.com/QuantumNous/new-api/logger" @@ -21,7 +23,7 @@ type Redemption struct { CreatedTime int64 `json:"created_time" gorm:"bigint"` RedeemedTime int64 `json:"redeemed_time" gorm:"bigint"` Count int `json:"count" gorm:"-:all"` // only for api request - UsedUserId int `json:"used_user_id"` + UsedUserId int `json:"used_user_id" gorm:"index"` DeletedAt gorm.DeletedAt `gorm:"index"` ExpiredTime int64 `json:"expired_time" gorm:"bigint"` // 过期时间,0 表示不过期 } @@ -74,8 +76,31 @@ func SearchRedemptions(keyword string, status string, startIdx int, num int) (re query := tx.Model(&Redemption{}) if keyword != "" { + keyCol := "`key`" + if common.UsingMainDatabase(common.DatabaseTypePostgreSQL) { + keyCol = `"key"` + } + normalizedKey := strings.ReplaceAll(strings.TrimSpace(keyword), "-", "") + exactKeyMatch := len(normalizedKey) == 32 + if exactKeyMatch { + _, decodeErr := hex.DecodeString(normalizedKey) + exactKeyMatch = decodeErr == nil + } + if id, err := strconv.Atoi(keyword); err == nil { - query = query.Where("id = ? OR name LIKE ?", id, keyword+"%") + if exactKeyMatch { + query = query.Where( + fmt.Sprintf("id = ? OR name LIKE ? OR %s = ?", keyCol), + id, keyword+"%", normalizedKey, + ) + } else { + query = query.Where("id = ? OR name LIKE ?", id, keyword+"%") + } + } else if exactKeyMatch { + query = query.Where( + fmt.Sprintf("name LIKE ? OR %s = ?", keyCol), + keyword+"%", normalizedKey, + ) } else { query = query.Where("name LIKE ?", keyword+"%") } diff --git a/model/redemption_test.go b/model/redemption_test.go index 0ba2e8e8e39f..ac9cdfceb58c 100644 --- a/model/redemption_test.go +++ b/model/redemption_test.go @@ -49,6 +49,27 @@ func TestSearchRedemptionsFiltersAndPaginates(t *testing.T) { wantTotal: 3, wantIds: []int{3, 2, 1}, }, + { + name: "keyword matches full redemption key exactly", + keyword: "00000000000000000000000000000002", + num: 10, + wantTotal: 1, + wantIds: []int{2}, + }, + { + name: "keyword matches full redemption key with dashes", + keyword: "00000000-0000-0000-0000-000000000003", + num: 10, + wantTotal: 1, + wantIds: []int{3}, + }, + { + name: "partial redemption key does not match by key", + keyword: "0000000000000000000000000000000", + num: 10, + wantTotal: 0, + wantIds: []int{}, + }, { name: "enabled status excludes expired rows", status: "1", diff --git a/router/api-router.go b/router/api-router.go index 83f9259b2132..c415b7d61889 100644 --- a/router/api-router.go +++ b/router/api-router.go @@ -23,6 +23,7 @@ func SetApiRouter(router *gin.Engine) { apiRouter.POST("/setup", anonymousRequestBodyLimit, controller.PostSetup) apiRouter.GET("/status", controller.GetStatus) apiRouter.GET("/uptime/status", controller.GetUptimeKumaStatus) + apiRouter.GET("/extensions/availability", middleware.UserAuth(), controller.GetExtensionsAvailability) apiRouter.GET("/models", middleware.UserAuth(), controller.DashboardListModels) apiRouter.GET("/status/test", middleware.AdminAuth(), controller.TestStatus) apiRouter.GET("/notice", controller.GetNotice) @@ -119,6 +120,10 @@ func SetApiRouter(router *gin.Engine) { selfRoute.GET("/checkin", controller.GetCheckinStatus) selfRoute.POST("/checkin", middleware.TurnstileCheck(), controller.DoCheckin) + // Lottery (slot machine) + selfRoute.GET("/lottery", controller.GetLotteryStatus) + selfRoute.POST("/lottery", middleware.TurnstileCheckStrict(), controller.DoLottery) + // Custom OAuth bindings selfRoute.GET("/oauth/bindings", controller.GetUserOAuthBindings) selfRoute.DELETE("/oauth/bindings/:provider_id", controller.UnbindCustomOAuth) diff --git a/setting/console_setting/availability.go b/setting/console_setting/availability.go new file mode 100644 index 000000000000..134dfbd86bdc --- /dev/null +++ b/setting/console_setting/availability.go @@ -0,0 +1,28 @@ +package console_setting + +// AvailabilityStatusFromSuccessRate maps overall success rate to badge status. +// total == 0 → ok (no data yet). +func AvailabilityStatusFromSuccessRate(successRate float64, total int) string { + if total <= 0 { + return "ok" + } + if successRate >= 0.95 { + return "ok" + } + if successRate >= 0.80 { + return "warn" + } + return "error" +} + +func SummarizeAvailabilityRecords(okCount int, total int, successUseTimeSum int) (successRate float64, avgUseTime float64, status string) { + if total <= 0 { + return 0, 0, "ok" + } + successRate = float64(okCount) / float64(total) + if okCount > 0 { + avgUseTime = float64(successUseTimeSum) / float64(okCount) + } + status = AvailabilityStatusFromSuccessRate(successRate, total) + return successRate, avgUseTime, status +} diff --git a/setting/console_setting/availability_test.go b/setting/console_setting/availability_test.go new file mode 100644 index 000000000000..0a1642727479 --- /dev/null +++ b/setting/console_setting/availability_test.go @@ -0,0 +1,85 @@ +package console_setting + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestAvailabilityStatusFromSuccessRate(t *testing.T) { + t.Parallel() + + assert.Equal(t, "ok", AvailabilityStatusFromSuccessRate(1, 0)) + assert.Equal(t, "ok", AvailabilityStatusFromSuccessRate(0.95, 100)) + assert.Equal(t, "ok", AvailabilityStatusFromSuccessRate(1, 100)) + assert.Equal(t, "warn", AvailabilityStatusFromSuccessRate(0.949, 100)) + assert.Equal(t, "warn", AvailabilityStatusFromSuccessRate(0.80, 100)) + assert.Equal(t, "error", AvailabilityStatusFromSuccessRate(0.799, 100)) +} + +func TestGetCustomPagesForRoleVisibility(t *testing.T) { + previous := consoleSetting.CustomPages + t.Cleanup(func() { + consoleSetting.CustomPages = previous + }) + + consoleSetting.CustomPages = `[ + {"id":"cp_all","title":"All","icon":"Link","url":"https://a.example.com","enabled":true,"visibility":"all","sort":1}, + {"id":"cp_admin","title":"Admin","icon":"Link","url":"https://b.example.com","enabled":true,"visibility":"admin","sort":2} + ]` + + forAll := GetCustomPagesForRole(false) + assert.Len(t, forAll, 1) + assert.Equal(t, "cp_all", forAll[0]["id"]) + + forAdmin := GetCustomPagesForRole(true) + assert.Len(t, forAdmin, 2) +} + +func TestIsAvailabilityMonitorVisible(t *testing.T) { + previousEnabled := consoleSetting.AvailabilityMonitorEnabled + previousVisibility := consoleSetting.AvailabilityMonitorVisibility + t.Cleanup(func() { + consoleSetting.AvailabilityMonitorEnabled = previousEnabled + consoleSetting.AvailabilityMonitorVisibility = previousVisibility + }) + + consoleSetting.AvailabilityMonitorEnabled = true + consoleSetting.AvailabilityMonitorVisibility = "all" + assert.True(t, IsAvailabilityMonitorVisible(false)) + assert.True(t, IsAvailabilityMonitorVisible(true)) + + consoleSetting.AvailabilityMonitorVisibility = "admin" + assert.False(t, IsAvailabilityMonitorVisible(false)) + assert.True(t, IsAvailabilityMonitorVisible(true)) + + consoleSetting.AvailabilityMonitorEnabled = false + assert.False(t, IsAvailabilityMonitorVisible(true)) +} + +func TestValidateAvailabilityMonitorRefreshInterval(t *testing.T) { + t.Parallel() + + assert.NoError(t, ValidateAvailabilityMonitorRefreshInterval("5")) + assert.NoError(t, ValidateAvailabilityMonitorRefreshInterval("10")) + assert.NoError(t, ValidateAvailabilityMonitorRefreshInterval("3600")) + assert.Error(t, ValidateAvailabilityMonitorRefreshInterval("4")) + assert.Error(t, ValidateAvailabilityMonitorRefreshInterval("3601")) + assert.Error(t, ValidateAvailabilityMonitorRefreshInterval("abc")) +} + +func TestGetAvailabilityMonitorRefreshInterval(t *testing.T) { + previous := consoleSetting.AvailabilityMonitorRefreshInterval + t.Cleanup(func() { + consoleSetting.AvailabilityMonitorRefreshInterval = previous + }) + + consoleSetting.AvailabilityMonitorRefreshInterval = 60 + assert.Equal(t, 60, GetAvailabilityMonitorRefreshInterval()) + + consoleSetting.AvailabilityMonitorRefreshInterval = 1 + assert.Equal(t, 5, GetAvailabilityMonitorRefreshInterval()) + + consoleSetting.AvailabilityMonitorRefreshInterval = 99999 + assert.Equal(t, 3600, GetAvailabilityMonitorRefreshInterval()) +} diff --git a/setting/console_setting/config.go b/setting/console_setting/config.go index 144e95c497be..84a696145011 100644 --- a/setting/console_setting/config.go +++ b/setting/console_setting/config.go @@ -3,26 +3,34 @@ package console_setting import "github.com/QuantumNous/new-api/setting/config" type ConsoleSetting struct { - ApiInfo string `json:"api_info"` // 控制台 API 信息 (JSON 数组字符串) - UptimeKumaGroups string `json:"uptime_kuma_groups"` // Uptime Kuma 分组配置 (JSON 数组字符串) - Announcements string `json:"announcements"` // 系统公告 (JSON 数组字符串) - FAQ string `json:"faq"` // 常见问题 (JSON 数组字符串) - ApiInfoEnabled bool `json:"api_info_enabled"` // 是否启用 API 信息面板 - UptimeKumaEnabled bool `json:"uptime_kuma_enabled"` // 是否启用 Uptime Kuma 面板 - AnnouncementsEnabled bool `json:"announcements_enabled"` // 是否启用系统公告面板 - FAQEnabled bool `json:"faq_enabled"` // 是否启用常见问答面板 + ApiInfo string `json:"api_info"` // 控制台 API 信息 (JSON 数组字符串) + UptimeKumaGroups string `json:"uptime_kuma_groups"` // Uptime Kuma 分组配置 (JSON 数组字符串) + Announcements string `json:"announcements"` // 系统公告 (JSON 数组字符串) + FAQ string `json:"faq"` // 常见问题 (JSON 数组字符串) + CustomPages string `json:"custom_pages"` // 拓展定制页面 (JSON 数组字符串) + AvailabilityMonitorEnabled bool `json:"availability_monitor_enabled"` // 是否启用拓展可用性监控 + AvailabilityMonitorVisibility string `json:"availability_monitor_visibility"` // 可用性监控可见范围: all | admin + AvailabilityMonitorRefreshInterval int `json:"availability_monitor_refresh_interval"` // 可用性监控前端自动刷新间隔(秒) + ApiInfoEnabled bool `json:"api_info_enabled"` // 是否启用 API 信息面板 + UptimeKumaEnabled bool `json:"uptime_kuma_enabled"` // 是否启用 Uptime Kuma 面板 + AnnouncementsEnabled bool `json:"announcements_enabled"` // 是否启用系统公告面板 + FAQEnabled bool `json:"faq_enabled"` // 是否启用常见问答面板 } // 默认配置 var defaultConsoleSetting = ConsoleSetting{ - ApiInfo: "", - UptimeKumaGroups: "", - Announcements: "", - FAQ: "", - ApiInfoEnabled: true, - UptimeKumaEnabled: true, - AnnouncementsEnabled: true, - FAQEnabled: true, + ApiInfo: "", + UptimeKumaGroups: "", + Announcements: "", + FAQ: "", + CustomPages: "[]", + AvailabilityMonitorEnabled: true, + AvailabilityMonitorVisibility: "all", + AvailabilityMonitorRefreshInterval: 10, + ApiInfoEnabled: true, + UptimeKumaEnabled: true, + AnnouncementsEnabled: true, + FAQEnabled: true, } // 全局实例 diff --git a/setting/console_setting/custom_pages_test.go b/setting/console_setting/custom_pages_test.go new file mode 100644 index 000000000000..75b22e8ba536 --- /dev/null +++ b/setting/console_setting/custom_pages_test.go @@ -0,0 +1,64 @@ +package console_setting + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestValidateCustomPages(t *testing.T) { + t.Parallel() + + require.NoError(t, ValidateConsoleSettings("[]", "CustomPages")) + require.NoError(t, ValidateConsoleSettings("", "CustomPages")) + + err := ValidateConsoleSettings(`[ + {"id":"cp_a","title":"Docs","icon":"BookOpen","url":"https://example.com","enabled":true,"open_mode":"external","sort":1} + ]`, "CustomPages") + require.NoError(t, err) + + err = ValidateConsoleSettings(`[ + {"id":"cp_a","title":"Docs","url":"https://example.com","enabled":true,"open_mode":"popup"} + ]`, "CustomPages") + require.Error(t, err) + + err = ValidateConsoleSettings(`[ + {"id":"bad id","title":"Docs","url":"https://example.com","enabled":true} + ]`, "CustomPages") + require.Error(t, err) + + err = ValidateConsoleSettings(`[ + {"id":"cp_a","title":"Docs","url":"not-a-url","enabled":true} + ]`, "CustomPages") + require.Error(t, err) + + err = ValidateConsoleSettings(`[ + {"id":"cp_a","title":"Docs","icon":"NotAnIcon","url":"https://example.com","enabled":true} + ]`, "CustomPages") + require.Error(t, err) +} + +func TestGetCustomPagesFiltersAndSorts(t *testing.T) { + previous := consoleSetting.CustomPages + t.Cleanup(func() { + consoleSetting.CustomPages = previous + }) + + consoleSetting.CustomPages = `[ + {"id":"cp_b","title":"B","icon":"Globe","url":"https://b.example.com","enabled":true,"open_mode":"external","sort":2}, + {"id":"cp_off","title":"Off","icon":"Link","url":"https://off.example.com","enabled":false,"sort":0}, + {"id":"cp_a","title":"A","icon":"BookOpen","url":"https://a.example.com","enabled":true,"sort":1}, + {"id":"cp_empty","title":"Empty","icon":"Link","url":"","enabled":true,"sort":0} + ]` + + pages := GetCustomPages() + require.Len(t, pages, 2) + assert.Equal(t, "cp_a", pages[0]["id"]) + assert.Equal(t, "cp_b", pages[1]["id"]) + assert.Equal(t, "BookOpen", pages[0]["icon"]) + assert.Equal(t, "embed", pages[0]["open_mode"]) + assert.Equal(t, "external", pages[1]["open_mode"]) + _, hasSort := pages[0]["sort"] + assert.False(t, hasSort) +} diff --git a/setting/console_setting/validation.go b/setting/console_setting/validation.go index d6e4342c3d8f..5c6e2e521e7b 100644 --- a/setting/console_setting/validation.go +++ b/setting/console_setting/validation.go @@ -6,6 +6,7 @@ import ( "net/url" "regexp" "sort" + "strconv" "strings" "time" ) @@ -73,11 +74,276 @@ func ValidateConsoleSettings(settingsStr string, settingType string) error { return validateFAQ(settingsStr) case "UptimeKumaGroups": return validateUptimeKumaGroups(settingsStr) + case "CustomPages": + return validateCustomPages(settingsStr) default: return fmt.Errorf("未知的设置类型:%s", settingType) } } +var validCustomPageIcons = map[string]bool{ + "Link": true, "BookOpen": true, "ExternalLink": true, "FileText": true, + "Globe": true, "Layout": true, "Newspaper": true, "HelpCircle": true, + "Bookmark": true, "FolderOpen": true, +} + +var validCustomPageOpenModes = map[string]bool{ + "embed": true, + "external": true, +} + +var validExtensionVisibilities = map[string]bool{ + "all": true, + "admin": true, +} + +func NormalizeExtensionVisibility(visibility string) string { + visibility = strings.TrimSpace(visibility) + if validExtensionVisibilities[visibility] { + return visibility + } + return "all" +} + +func IsAvailabilityMonitorVisible(isAdmin bool) bool { + cs := GetConsoleSetting() + if !cs.AvailabilityMonitorEnabled { + return false + } + visibility := NormalizeExtensionVisibility(cs.AvailabilityMonitorVisibility) + if visibility == "admin" { + return isAdmin + } + return true +} + +func ValidateAvailabilityMonitorVisibility(value string) error { + if !validExtensionVisibilities[strings.TrimSpace(value)] { + return fmt.Errorf("可用性监控可见范围不合法,仅支持 all 或 admin") + } + return nil +} + +const ( + availabilityMonitorRefreshIntervalMin = 5 + availabilityMonitorRefreshIntervalMax = 3600 +) + +func ValidateAvailabilityMonitorRefreshInterval(value string) error { + seconds, err := strconv.Atoi(strings.TrimSpace(value)) + if err != nil { + return fmt.Errorf("可用性监控刷新间隔必须是整数秒") + } + if seconds < availabilityMonitorRefreshIntervalMin || seconds > availabilityMonitorRefreshIntervalMax { + return fmt.Errorf( + "可用性监控刷新间隔须在 %d–%d 秒之间", + availabilityMonitorRefreshIntervalMin, + availabilityMonitorRefreshIntervalMax, + ) + } + return nil +} + +// GetAvailabilityMonitorRefreshInterval returns the configured auto-refresh +// interval in seconds, clamped to the allowed range. +func GetAvailabilityMonitorRefreshInterval() int { + seconds := GetConsoleSetting().AvailabilityMonitorRefreshInterval + if seconds < availabilityMonitorRefreshIntervalMin { + return availabilityMonitorRefreshIntervalMin + } + if seconds > availabilityMonitorRefreshIntervalMax { + return availabilityMonitorRefreshIntervalMax + } + return seconds +} + +func getJSONString(item map[string]interface{}, key string) (string, bool) { + v, ok := item[key].(string) + return v, ok +} + +func getJSONBool(item map[string]interface{}, key string) (bool, bool) { + v, ok := item[key].(bool) + return v, ok +} + +func getJSONSort(item map[string]interface{}) int { + v, exists := item["sort"] + if !exists || v == nil { + return 0 + } + switch n := v.(type) { + case float64: + return int(n) + case int: + return n + case int64: + return int(n) + case json.Number: + i, err := n.Int64() + if err != nil { + return 0 + } + return int(i) + default: + return 0 + } +} + +func validateCustomPages(customPagesStr string) error { + list, err := parseJSONArray(customPagesStr, "定制页面") + if err != nil { + return err + } + + idSet := make(map[string]bool) + for i, page := range list { + id, ok := getJSONString(page, "id") + if !ok || strings.TrimSpace(id) == "" { + return fmt.Errorf("第%d个定制页面缺少id字段", i+1) + } + id = strings.TrimSpace(id) + if len(id) > 64 { + return fmt.Errorf("第%d个定制页面的id长度不能超过64字符", i+1) + } + if !slugRegex.MatchString(id) { + return fmt.Errorf("第%d个定制页面的id只能包含字母、数字、下划线和连字符", i+1) + } + if idSet[id] { + return fmt.Errorf("第%d个定制页面的id与其他项重复", i+1) + } + idSet[id] = true + + title, ok := getJSONString(page, "title") + if !ok || strings.TrimSpace(title) == "" { + return fmt.Errorf("第%d个定制页面缺少标题字段", i+1) + } + title = strings.TrimSpace(title) + if len(title) > 100 { + return fmt.Errorf("第%d个定制页面的标题长度不能超过100字符", i+1) + } + if err := checkDangerousContent(title, i+1, "定制页面"); err != nil { + return err + } + + urlStr, ok := getJSONString(page, "url") + if !ok { + urlStr = "" + } + urlStr = strings.TrimSpace(urlStr) + if urlStr != "" { + if err := validateURL(urlStr, i+1, "定制页面"); err != nil { + return err + } + if len(urlStr) > 500 { + return fmt.Errorf("第%d个定制页面的URL长度不能超过500字符", i+1) + } + } + + icon, ok := getJSONString(page, "icon") + if ok && strings.TrimSpace(icon) != "" { + icon = strings.TrimSpace(icon) + if !validCustomPageIcons[icon] { + return fmt.Errorf("第%d个定制页面的图标不在预设列表中", i+1) + } + } + + if _, exists := page["enabled"]; exists { + if _, ok := getJSONBool(page, "enabled"); !ok { + return fmt.Errorf("第%d个定制页面的enabled字段必须是布尔值", i+1) + } + } + + if openMode, exists := page["open_mode"]; exists && openMode != nil { + openModeStr, ok := openMode.(string) + if !ok || !validCustomPageOpenModes[strings.TrimSpace(openModeStr)] { + return fmt.Errorf("第%d个定制页面的打开方式不合法,仅支持 embed 或 external", i+1) + } + } + + if visibility, exists := page["visibility"]; exists && visibility != nil { + visibilityStr, ok := visibility.(string) + if !ok || !validExtensionVisibilities[strings.TrimSpace(visibilityStr)] { + return fmt.Errorf("第%d个定制页面的可见范围不合法,仅支持 all 或 admin", i+1) + } + } + + if _, exists := page["sort"]; exists && page["sort"] != nil { + switch page["sort"].(type) { + case float64, int, int64, json.Number: + default: + return fmt.Errorf("第%d个定制页面的sort字段必须是数字", i+1) + } + } + } + return nil +} + +// GetCustomPages returns enabled custom pages visible to admins (all visibilities). +func GetCustomPages() []map[string]interface{} { + return GetCustomPagesForRole(true) +} + +// GetCustomPagesForRole returns enabled custom pages with non-empty URLs for the given role. +func GetCustomPagesForRole(isAdmin bool) []map[string]interface{} { + list := getJSONList(GetConsoleSetting().CustomPages) + result := make([]map[string]interface{}, 0, len(list)) + for _, page := range list { + enabled, hasEnabled := getJSONBool(page, "enabled") + if hasEnabled && !enabled { + continue + } + if !hasEnabled { + continue + } + urlStr, _ := getJSONString(page, "url") + urlStr = strings.TrimSpace(urlStr) + if urlStr == "" { + continue + } + visibility, _ := getJSONString(page, "visibility") + visibility = NormalizeExtensionVisibility(visibility) + if visibility == "admin" && !isAdmin { + continue + } + id, _ := getJSONString(page, "id") + title, _ := getJSONString(page, "title") + icon, _ := getJSONString(page, "icon") + icon = strings.TrimSpace(icon) + if icon == "" || !validCustomPageIcons[icon] { + icon = "Link" + } + openMode, _ := getJSONString(page, "open_mode") + openMode = strings.TrimSpace(openMode) + if !validCustomPageOpenModes[openMode] { + openMode = "embed" + } + result = append(result, map[string]interface{}{ + "id": strings.TrimSpace(id), + "title": strings.TrimSpace(title), + "icon": icon, + "url": urlStr, + "open_mode": openMode, + "sort": getJSONSort(page), + }) + } + sort.SliceStable(result, func(i, j int) bool { + si := getJSONSort(result[i]) + sj := getJSONSort(result[j]) + if si != sj { + return si < sj + } + idi, _ := result[i]["id"].(string) + idj, _ := result[j]["id"].(string) + return idi < idj + }) + // Strip sort from public payload + for _, page := range result { + delete(page, "sort") + } + return result +} + func validateApiInfo(apiInfoStr string) error { apiInfoList, err := parseJSONArray(apiInfoStr, "API信息") if err != nil { diff --git a/setting/operation_setting/lottery_setting.go b/setting/operation_setting/lottery_setting.go new file mode 100644 index 000000000000..20ef4b85e070 --- /dev/null +++ b/setting/operation_setting/lottery_setting.go @@ -0,0 +1,228 @@ +package operation_setting + +import ( + "encoding/json" + "errors" + "math" + + "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/setting/config" +) + +// LotteryPrize 抽奖奖项配置(金额单位:美元 USD) +type LotteryPrize struct { + Name string `json:"name"` + Usd float64 `json:"usd"` // 免费模式奖励美元金额 + Quota int `json:"quota,omitempty"` // 已废弃:旧额度字段,加载时自动换算为 usd + Multiplier float64 `json:"multiplier"` // 投入模式倍率,范围 [-1, 2] + Weight int `json:"weight"` + IsThanks bool `json:"is_thanks"` +} + +// LotterySetting 老虎机抽奖配置(金额单位:美元 USD) +type LotterySetting struct { + Enabled bool `json:"enabled"` + // DailyPoolUSD 实际每日支出上限(真实结算用,不对用户展示) + DailyPoolUSD float64 `json:"daily_pool_usd"` + // DisplayDailyPoolUSD 用户可见的展示奖池;<=0 时回退为 DailyPoolUSD + DisplayDailyPoolUSD float64 `json:"display_daily_pool_usd"` + MinBetUSD float64 `json:"min_bet_usd"` + MaxBetUSD float64 `json:"max_bet_usd"` + MaxDrawsPerIPPerDay int `json:"max_draws_per_ip_per_day"` // 0=不限制 + // RequireRedemption 平时需至少成功兑换过一次兑换码才可参与;疯狂星期四跳过此限制。 + RequireRedemption bool `json:"require_redemption"` + FreePrizes []LotteryPrize `json:"free_prizes"` + BetPrizes []LotteryPrize `json:"bet_prizes"` +} + +var lotterySetting = LotterySetting{ + Enabled: false, + DailyPoolUSD: 100, // 实际限额 $100 / day + DisplayDailyPoolUSD: 8888, // 展示给用户的每日奖池 + MinBetUSD: 0.01, // $0.01 + MaxBetUSD: 10, // $10 + MaxDrawsPerIPPerDay: 3, + RequireRedemption: true, + FreePrizes: []LotteryPrize{ + {Name: "谢谢惠顾", Usd: 0, Weight: 28, IsThanks: true}, + {Name: "安慰奖", Usd: 0.01, Weight: 18}, + {Name: "小奖", Usd: 0.05, Weight: 15}, + {Name: "普通奖", Usd: 0.2, Weight: 12}, + {Name: "中奖", Usd: 0.5, Weight: 10}, + {Name: "大奖", Usd: 1, Weight: 7}, + {Name: "超级大奖", Usd: 2, Weight: 5}, + {Name: "传说奖", Usd: 5, Weight: 3}, + {Name: "头奖", Usd: 20, Weight: 2}, + }, + BetPrizes: []LotteryPrize{ + {Name: "血本无归", Multiplier: -1, Weight: 12}, + {Name: "大亏", Multiplier: -0.5, Weight: 12}, + {Name: "小亏", Multiplier: -0.2, Weight: 14}, + {Name: "谢谢惠顾", Multiplier: 0, Weight: 18, IsThanks: true}, + {Name: "回本碎银", Multiplier: 0.2, Weight: 14}, + {Name: "小赚", Multiplier: 0.5, Weight: 12}, + {Name: "翻倍", Multiplier: 1, Weight: 8}, + {Name: "大赚", Multiplier: 1.5, Weight: 6}, + {Name: "暴击", Multiplier: 2, Weight: 4}, + }, +} + +func init() { + config.GlobalConfig.Register("lottery_setting", &lotterySetting) +} + +// GetLotterySetting 获取抽奖配置 +func GetLotterySetting() *LotterySetting { + normalizeLotterySetting(&lotterySetting) + return &lotterySetting +} + +// normalizeLotterySetting 兼容旧版额度字段 / 旧 option key 语义 +func normalizeLotterySetting(s *LotterySetting) { + if s == nil { + return + } + for i := range s.FreePrizes { + if s.FreePrizes[i].Usd == 0 && s.FreePrizes[i].Quota > 0 { + s.FreePrizes[i].Usd = QuotaToUsd(s.FreePrizes[i].Quota) + s.FreePrizes[i].Quota = 0 + } + } +} + +// IsLotteryEnabled 是否启用抽奖 +func IsLotteryEnabled() bool { + return lotterySetting.Enabled +} + +// UsdToQuota 美元转系统额度(默认 500000 额度 = $1) +func UsdToQuota(usd float64) int { + if usd == 0 { + return 0 + } + return int(math.Round(usd * common.QuotaPerUnit)) +} + +// QuotaToUsd 系统额度转美元 +func QuotaToUsd(quota int) float64 { + if common.QuotaPerUnit <= 0 { + return 0 + } + return float64(quota) / common.QuotaPerUnit +} + +// ValidateLotterySetting 校验配置合法性 +func ValidateLotterySetting(s *LotterySetting) error { + if s == nil { + return errors.New("lottery setting is nil") + } + if s.DailyPoolUSD < 0 { + return errors.New("daily_pool_usd must be >= 0") + } + if s.DisplayDailyPoolUSD < 0 { + return errors.New("display_daily_pool_usd must be >= 0") + } + if s.MinBetUSD < 0 || s.MaxBetUSD < 0 { + return errors.New("bet usd must be >= 0") + } + if s.MinBetUSD > s.MaxBetUSD { + return errors.New("min_bet_usd must be <= max_bet_usd") + } + if s.MaxDrawsPerIPPerDay < 0 { + return errors.New("max_draws_per_ip_per_day must be >= 0") + } + if err := validatePrizeList(s.FreePrizes, false); err != nil { + return err + } + if err := validatePrizeList(s.BetPrizes, true); err != nil { + return err + } + return nil +} + +func validatePrizeList(prizes []LotteryPrize, betMode bool) error { + if len(prizes) == 0 { + return errors.New("prize list cannot be empty") + } + totalWeight := 0 + for _, p := range prizes { + if p.Name == "" { + return errors.New("prize name cannot be empty") + } + if p.Weight <= 0 { + return errors.New("prize weight must be > 0") + } + if betMode { + if p.Multiplier < -1 || p.Multiplier > 2 { + return errors.New("bet prize multiplier must be in [-1, 2]") + } + } else if p.Usd < 0 { + return errors.New("free prize usd must be >= 0") + } + totalWeight += p.Weight + } + if totalWeight <= 0 { + return errors.New("total prize weight must be > 0") + } + return nil +} + +// EffectiveFreeUSD 计算免费奖项当天有效美元(周四翻倍) +func EffectiveFreeUSD(usd float64, isThursday bool) float64 { + if usd <= 0 { + return 0 + } + if isThursday { + return usd * 2 + } + return usd +} + +// EffectiveDailyPoolUSD 计算当日有效奖池(美元) +func EffectiveDailyPoolUSD(base float64, isThursday bool) float64 { + if base < 0 { + base = 0 + } + if isThursday { + return base * 2 + } + return base +} + +// ResolvedDisplayDailyPoolUSD 用户可见展示奖池基数(未含周四翻倍) +func ResolvedDisplayDailyPoolUSD(s *LotterySetting) float64 { + if s == nil { + return 0 + } + if s.DisplayDailyPoolUSD > 0 { + return s.DisplayDailyPoolUSD + } + return s.DailyPoolUSD +} + +// EffectiveFreeQuota 免费奖项当天有效额度 +func EffectiveFreeQuota(usd float64, isThursday bool) int { + return UsdToQuota(EffectiveFreeUSD(usd, isThursday)) +} + +// EffectiveDailyPoolQuota 当日有效奖池额度 +func EffectiveDailyPoolQuota(baseUSD float64, isThursday bool) int { + return UsdToQuota(EffectiveDailyPoolUSD(baseUSD, isThursday)) +} + +// RoundBetDelta 按倍率计算净额度变化(bet 为额度) +func RoundBetDelta(betQuota int, multiplier float64) int { + if betQuota <= 0 { + return 0 + } + return int(math.Round(float64(betQuota) * multiplier)) +} + +// LotteryPrizesJSON 序列化奖项 +func LotteryPrizesJSON(prizes []LotteryPrize) string { + b, err := json.Marshal(prizes) + if err != nil { + return "[]" + } + return string(b) +} diff --git a/setting/operation_setting/lottery_setting_test.go b/setting/operation_setting/lottery_setting_test.go new file mode 100644 index 000000000000..03cb5a3165b7 --- /dev/null +++ b/setting/operation_setting/lottery_setting_test.go @@ -0,0 +1,52 @@ +package operation_setting + +import "testing" + +func TestEffectiveDailyPoolUSDThursday(t *testing.T) { + if got := EffectiveDailyPoolUSD(100, false); got != 100 { + t.Fatalf("want 100, got %v", got) + } + if got := EffectiveDailyPoolUSD(100, true); got != 200 { + t.Fatalf("want 200, got %v", got) + } +} + +func TestEffectiveFreeUSDThursday(t *testing.T) { + if got := EffectiveFreeUSD(0.5, true); got != 1 { + t.Fatalf("want 1, got %v", got) + } + if got := EffectiveFreeUSD(0, true); got != 0 { + t.Fatalf("want 0, got %v", got) + } +} + +func TestUsdToQuota(t *testing.T) { + // default QuotaPerUnit is 500000 + if got := UsdToQuota(1); got != 500000 { + t.Fatalf("want 500000, got %d", got) + } + if got := UsdToQuota(0.01); got != 5000 { + t.Fatalf("want 5000, got %d", got) + } +} + +func TestRoundBetDelta(t *testing.T) { + if got := RoundBetDelta(1000, 2); got != 2000 { + t.Fatalf("want 2000, got %d", got) + } + if got := RoundBetDelta(1000, -1); got != -1000 { + t.Fatalf("want -1000, got %d", got) + } +} + +func TestValidateLotterySetting(t *testing.T) { + s := GetLotterySetting() + if err := ValidateLotterySetting(s); err != nil { + t.Fatalf("default setting should be valid: %v", err) + } + bad := *s + bad.BetPrizes = []LotteryPrize{{Name: "x", Multiplier: 3, Weight: 1}} + if err := ValidateLotterySetting(&bad); err == nil { + t.Fatal("expected multiplier > 2 to fail") + } +} diff --git a/web/bun.lock b/web/bun.lock index d86f3a8b3ffe..ea016be6c373 100644 --- a/web/bun.lock +++ b/web/bun.lock @@ -4,6 +4,10 @@ "workspaces": { "": { "name": "new-api-web-workspace", + "dependencies": { + "@lobehub/icons": "^5.13.0", + "es-toolkit": "1.47.0", + }, }, "classic": { "name": "react-template", @@ -79,7 +83,7 @@ "@hugeicons/core-free-icons": "^4.2.2", "@hugeicons/react": "^1.1.9", "@lezer/highlight": "^1.2.3", - "@lobehub/icons": "catalog:", + "@lobehub/icons": "^5.10.1", "@tanstack/react-query": "^5.101.2", "@tanstack/react-router": "^1.170.17", "@tanstack/react-table": "^8.21.3", @@ -456,7 +460,7 @@ "@lobehub/fluent-emoji": ["@lobehub/fluent-emoji@4.1.0", "", { "dependencies": { "@lobehub/emojilib": "^1.0.0", "antd-style": "^4.1.0", "emoji-regex": "^10.6.0", "es-toolkit": "^1.43.0", "lucide-react": "^0.562.0", "url-join": "^5.0.0" }, "peerDependencies": { "react": "^19.0.0", "react-dom": "^19.0.0" } }, "sha512-R1MB2lfUkDvB7XAQdRzY75c1dx/tB7gEvBPaEEMarzKfCJWmXm7rheS6caVzmgwAlq5sfmTbxPL+un99sp//Yw=="], - "@lobehub/icons": ["@lobehub/icons@5.10.1", "", { "dependencies": { "antd-style": "^4.1.0", "es-toolkit": "^1.45.1", "lucide-react": "^0.469.0", "polished": "^4.3.1" }, "peerDependencies": { "@lobehub/ui": "^5.0.0", "antd": "^6.1.1", "react": "^19.0.0", "react-dom": "^19.0.0" } }, "sha512-KMaE+YqPAXuA8gcmzBFefLa9KgCqmJy9Mg3tlGedrL2coAzCQeps+aqivjejHNMnCDTPnGb+OHvX1um2kT1lQw=="], + "@lobehub/icons": ["@lobehub/icons@5.13.0", "", { "dependencies": { "antd-style": "^4.1.0", "es-toolkit": "^1.49.0", "lucide-react": "^0.469.0", "polished": "^4.3.1" }, "peerDependencies": { "@lobehub/ui": "^5.0.0", "antd": "^6.1.1", "react": "^19.0.0", "react-dom": "^19.0.0" } }, "sha512-iXQF8GFvlwNJMR+PaU3jCgVAn5B8F7P48Fm6aodSXP+b+HJiR266rvlMSYvCULRAB/6/rtS1WZH3npc3p3viFw=="], "@lobehub/ui": ["@lobehub/ui@5.15.6", "", { "dependencies": { "@ant-design/cssinjs": "^2.1.2", "@base-ui/react": "1.5.0", "@dnd-kit/core": "^6.3.1", "@dnd-kit/modifiers": "^9.0.0", "@dnd-kit/sortable": "^10.0.0", "@dnd-kit/utilities": "^3.2.2", "@emoji-mart/data": "^1.2.1", "@emoji-mart/react": "^1.1.1", "@emotion/is-prop-valid": "^1.4.0", "@floating-ui/react": "^0.27.19", "@giscus/react": "^3.1.0", "@mdx-js/mdx": "^3.1.1", "@mdx-js/react": "^3.1.1", "@pierre/diffs": "^1.1.19", "@radix-ui/react-slot": "^1.2.4", "@shikijs/core": "^4.0.2", "@shikijs/transformers": "^4.0.2", "@splinetool/runtime": "0.9.526", "ahooks": "^3.9.7", "antd-style": "^4.1.0", "chroma-js": "^3.2.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "dayjs": "^1.11.20", "emoji-mart": "^5.6.0", "es-toolkit": "^1.46.0", "fast-deep-equal": "^3.1.3", "immer": "^11.1.4", "katex": "^0.16.45", "leva": "^0.10.1", "lucide-react": "^1.11.0", "marked": "^17.0.6", "mermaid": "^11.14.0", "motion": "^12.38.0", "numeral": "^2.0.6", "polished": "^4.3.1", "query-string": "^9.3.1", "rc-collapse": "^4.0.0", "rc-footer": "^0.6.8", "rc-image": "^7.12.0", "rc-input-number": "^9.5.0", "rc-menu": "^9.16.1", "re-resizable": "^6.11.2", "react-avatar-editor": "^15.1.0", "react-error-boundary": "^6.1.1", "react-hotkeys-hook": "^5.2.4", "react-markdown": "^10.1.0", "react-merge-refs": "^3.0.2", "react-rnd": "^10.5.3", "react-zoom-pan-pinch": "^3.7.0", "rehype-github-alerts": "^4.2.0", "rehype-katex": "^7.0.1", "rehype-raw": "^7.0.0", "remark-breaks": "^4.0.0", "remark-cjk-friendly": "^2.0.1", "remark-gfm": "^4.0.1", "remark-github": "^12.0.0", "remark-math": "^6.0.0", "remend": "^1.3.0", "shiki": "^4.0.2", "shiki-stream": "^0.1.4", "swr": "^2.4.1", "ts-md5": "^2.0.1", "unified": "^11.0.5", "url-join": "^5.0.0", "use-merge-value": "^1.2.0", "uuid": "^13.0.0", "virtua": "^0.49.1" }, "peerDependencies": { "@lobehub/fluent-emoji": "^4.0.0", "@lobehub/icons": "^5.0.0", "antd": "^6.1.1", "react": "^19.0.0", "react-dom": "^19.0.0" } }, "sha512-sjx95F9viJWRuhFlhe+pN7y6/b+dv9U6ysMcO8F+sFUQNYTBfUl80UkBLclHQc2adpxdrkzEN+0g0AXeFsCC1g=="], @@ -2966,6 +2970,8 @@ "@lobehub/fluent-emoji/lucide-react": ["lucide-react@0.562.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-82hOAu7y0dbVuFfmO4bYF1XEwYk/mEbM5E+b1jgci/udUBEE/R7LF5Ip0CCEmXe8AybRM8L+04eP+LGZeDvkiw=="], + "@lobehub/icons/es-toolkit": ["es-toolkit@1.49.0", "", {}, "sha512-G5iZ6Pc/FNRY/soKZHC+TxGDD83rHUDXxzaWhGCX44vAv/tMs56WMusnm/KMNK+luUPsgA9U28cGr4RDlSzL2g=="], + "@lobehub/icons/lucide-react": ["lucide-react@0.469.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-28vvUnnKQ/dBwiCQtwJw7QauYnE7yd2Cyp4tTTJpvglX4EMpbflcdBgrgToX2j71B3YvugK/NH3BGUk+E/p/Fw=="], "@lobehub/ui/@base-ui/react": ["@base-ui/react@1.5.0", "", { "dependencies": { "@babel/runtime": "^7.29.2", "@base-ui/utils": "0.2.9", "@floating-ui/react-dom": "^2.1.8", "@floating-ui/utils": "^0.2.11", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "@date-fns/tz": "^1.2.0", "@types/react": "^17 || ^18 || ^19", "date-fns": "^4.0.0", "react": "^17 || ^18 || ^19", "react-dom": "^17 || ^18 || ^19" }, "optionalPeers": ["@date-fns/tz", "@types/react", "date-fns"] }, "sha512-z1gSAlced1yY+iM+mHDEtIkD8UI3Ebs52MuBPxvV6f5hRutk+xvCH/wuB7hDqDzK9JG5FoMz5nhrqtSs1wjt1A=="], diff --git a/web/default/package.json b/web/default/package.json index 67906f26f473..d903437ca3d9 100644 --- a/web/default/package.json +++ b/web/default/package.json @@ -30,7 +30,7 @@ "@hugeicons/core-free-icons": "^4.2.2", "@hugeicons/react": "^1.1.9", "@lezer/highlight": "^1.2.3", - "@lobehub/icons": "catalog:", + "@lobehub/icons": "^5.10.1", "@tanstack/react-query": "^5.101.2", "@tanstack/react-router": "^1.170.17", "@tanstack/react-table": "^8.21.3", diff --git a/web/default/src/components/layout/config/system-settings.config.ts b/web/default/src/components/layout/config/system-settings.config.ts index 8469c0278649..6486599e3b1e 100644 --- a/web/default/src/components/layout/config/system-settings.config.ts +++ b/web/default/src/components/layout/config/system-settings.config.ts @@ -16,9 +16,10 @@ along with this program. If not, see . For commercial licensing, please contact support@quantumnous.com */ -import { type TFunction } from 'i18next' +import type { TFunction } from 'i18next' import { Box, + Blocks, CreditCard, Layout, Settings, @@ -30,6 +31,7 @@ import { import { getAuthSectionNavItems } from '@/features/system-settings/auth/section-registry.tsx' import { getBillingSectionNavItems } from '@/features/system-settings/billing/section-registry.tsx' import { getContentSectionNavItems } from '@/features/system-settings/content/section-registry.tsx' +import { getExtensionsSectionNavItems } from '@/features/system-settings/extensions/section-registry' import { getModelsSectionNavItems } from '@/features/system-settings/models/section-registry.tsx' import { getOperationsSectionNavItems } from '@/features/system-settings/operations/section-registry.tsx' import { getSecuritySectionNavItems } from '@/features/system-settings/security/section-registry.tsx' @@ -80,6 +82,11 @@ function getSystemSettingsNavGroups(t: TFunction): NavGroup[] { icon: Layout, items: getContentSectionNavItems(t), }, + { + title: t('Extensions'), + icon: Blocks, + items: getExtensionsSectionNavItems(t), + }, { title: t('Operations'), icon: Wrench, diff --git a/web/default/src/components/sign-out-dialog.tsx b/web/default/src/components/sign-out-dialog.tsx index 537002d99e49..fb8106fc990d 100644 --- a/web/default/src/components/sign-out-dialog.tsx +++ b/web/default/src/components/sign-out-dialog.tsx @@ -42,6 +42,7 @@ export function SignOutDialog({ open, onOpenChange }: SignOutDialogProps) { try { if (typeof window !== 'undefined') { window.localStorage.removeItem('uid') + window.localStorage.removeItem('status') } } catch { /* empty */ diff --git a/web/default/src/components/turnstile.tsx b/web/default/src/components/turnstile.tsx index 87b2c92e43b1..4c62d0d018ed 100644 --- a/web/default/src/components/turnstile.tsx +++ b/web/default/src/components/turnstile.tsx @@ -16,12 +16,20 @@ along with this program. If not, see . For commercial licensing, please contact support@quantumnous.com */ -import { useEffect, useRef } from 'react' +import { useEffect, useRef, useState } from 'react' +import { useTranslation } from 'react-i18next' + +import { cn } from '@/lib/utils' declare global { interface Window { turnstile?: { - render: (element: HTMLElement, options: Record) => void + render: ( + element: HTMLElement, + options: Record + ) => string | undefined + reset: (widgetId?: string) => void + remove: (widgetId?: string) => void } } } @@ -30,47 +38,156 @@ interface TurnstileProps { siteKey: string onVerify: (token: string) => void onExpire?: () => void + onError?: () => void className?: string } +const SCRIPT_ID = 'cf-turnstile' +const SCRIPT_SRC = + 'https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit' + +function loadTurnstileScript(): Promise { + if (typeof window === 'undefined') { + return Promise.reject(new Error('no window')) + } + if (window.turnstile) { + return Promise.resolve() + } + + const existing = document.getElementById(SCRIPT_ID) as HTMLScriptElement | null + if (existing) { + return new Promise((resolve, reject) => { + if (window.turnstile) { + resolve() + return + } + existing.addEventListener('load', () => resolve(), { once: true }) + existing.addEventListener( + 'error', + () => reject(new Error('turnstile script failed')), + { once: true } + ) + // Script may already be loaded but turnstile not yet attached + let tries = 0 + const timer = window.setInterval(() => { + tries += 1 + if (window.turnstile) { + window.clearInterval(timer) + resolve() + } else if (tries > 40) { + window.clearInterval(timer) + reject(new Error('turnstile script timeout')) + } + }, 50) + }) + } + + return new Promise((resolve, reject) => { + const s = document.createElement('script') + s.id = SCRIPT_ID + s.src = SCRIPT_SRC + s.async = true + s.defer = true + s.onload = () => resolve() + s.onerror = () => reject(new Error('turnstile script failed')) + document.head.appendChild(s) + }) +} + export function Turnstile({ siteKey, onVerify, onExpire, + onError, className, }: TurnstileProps) { + const { t } = useTranslation() const ref = useRef(null) + const widgetIdRef = useRef(undefined) + const [error, setError] = useState(null) useEffect(() => { - const render = () => { - if (!ref.current || !window.turnstile) return + let cancelled = false + + const mount = async () => { + setError(null) + if (!siteKey) { + setError(t('Turnstile site key is missing')) + onError?.() + return + } + if (!ref.current) return + try { - window.turnstile.render(ref.current, { + await loadTurnstileScript() + if (cancelled || !ref.current || !window.turnstile) return + + // Clear previous widget in this container + if (widgetIdRef.current) { + try { + window.turnstile.remove(widgetIdRef.current) + } catch { + /* empty */ + } + widgetIdRef.current = undefined + } + ref.current.innerHTML = '' + + const id = window.turnstile.render(ref.current, { sitekey: siteKey, - callback: (token: string) => onVerify(token), - 'error-callback': () => onExpire?.(), - 'expired-callback': () => onExpire?.(), + callback: (token: string) => { + setError(null) + onVerify(token) + }, + 'error-callback': () => { + setError( + t( + 'Turnstile failed to load. Check that this domain is allowed in Cloudflare Turnstile hostnames.' + ) + ) + onExpire?.() + onError?.() + }, + 'expired-callback': () => { + onExpire?.() + }, }) + widgetIdRef.current = typeof id === 'string' ? id : undefined } catch { - /* empty */ + if (!cancelled) { + setError( + t( + 'Turnstile script could not be loaded. Check network / ad blockers.' + ) + ) + onError?.() + } } } - if (window.turnstile) { - render() - return + void mount() + + return () => { + cancelled = true + if (widgetIdRef.current && window.turnstile) { + try { + window.turnstile.remove(widgetIdRef.current) + } catch { + /* empty */ + } + widgetIdRef.current = undefined + } } - const scriptId = 'cf-turnstile' - if (document.getElementById(scriptId)) return - const s = document.createElement('script') - s.id = scriptId - s.src = - 'https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit' - s.async = true - s.defer = true - s.onload = () => render() - document.head.appendChild(s) - }, [siteKey, onVerify, onExpire]) + // intentionally not depending on callbacks to avoid remount loops + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [siteKey, t]) - return
+ return ( +
+
+ {error ? ( +

{error}

+ ) : null} +
+ ) } diff --git a/web/default/src/features/auth/hooks/use-auth-redirect.ts b/web/default/src/features/auth/hooks/use-auth-redirect.ts index 1da607161a8d..acdca1d789c4 100644 --- a/web/default/src/features/auth/hooks/use-auth-redirect.ts +++ b/web/default/src/features/auth/hooks/use-auth-redirect.ts @@ -17,6 +17,7 @@ along with this program. If not, see . For commercial licensing, please contact support@quantumnous.com */ import { useNavigate } from '@tanstack/react-router' +import { useQueryClient } from '@tanstack/react-query' import i18n from 'i18next' import type { User } from '@/features/users/types' @@ -43,11 +44,20 @@ function getSavedLanguage(user: User): string | undefined { } } +function clearCachedStatus() { + try { + window.localStorage.removeItem('status') + } catch { + /* empty */ + } +} + /** * Hook for handling authentication redirects and user data management */ export function useAuthRedirect() { const navigate = useNavigate() + const queryClient = useQueryClient() const { auth } = useAuthStore() /** @@ -64,6 +74,11 @@ export function useAuthRedirect() { saveUserId(userData.id) } + // Status is role-sensitive (e.g. Extensions / Availability Monitor). + // Clear any pre-login cache so the sidebar refetches with the session. + clearCachedStatus() + await queryClient.invalidateQueries({ queryKey: ['status'] }) + // Fetch and set user data try { const self = await getSelf() diff --git a/web/default/src/features/auth/sign-up/components/sign-up-form.tsx b/web/default/src/features/auth/sign-up/components/sign-up-form.tsx index d0674fed32c1..279731183c87 100644 --- a/web/default/src/features/auth/sign-up/components/sign-up-form.tsx +++ b/web/default/src/features/auth/sign-up/components/sign-up-form.tsx @@ -337,11 +337,18 @@ export function SignUpForm({ {/* Turnstile */} {isTurnstileEnabled && ( -
+
setTurnstileToken('')} + onError={() => setTurnstileToken('')} /> + {!turnstileToken ? ( +

+ {t('Human verification is required before you can continue.')} +

+ ) : null}
)} diff --git a/web/default/src/features/auth/types.ts b/web/default/src/features/auth/types.ts index 21ab480bd189..a8e3e6a1071b 100644 --- a/web/default/src/features/auth/types.ts +++ b/web/default/src/features/auth/types.ts @@ -172,6 +172,15 @@ export interface SystemStatus { password_login_enabled?: boolean password_register_enabled?: boolean custom_oauth_providers?: CustomOAuthProviderInfo[] + custom_pages?: Array<{ + id: string + title: string + icon: string + url: string + open_mode?: 'embed' | 'external' + }> + availability_monitor_visible?: boolean + availability_monitor_refresh_interval?: number [key: string]: unknown } diff --git a/web/default/src/features/extensions/availability/api.ts b/web/default/src/features/extensions/availability/api.ts new file mode 100644 index 000000000000..43bce5a4fd6a --- /dev/null +++ b/web/default/src/features/extensions/availability/api.ts @@ -0,0 +1,49 @@ +/* +Copyright (C) 2023-2026 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, + 70|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 . + +For commercial licensing, please contact support@quantumnous.com +*/ +import { api } from '@/lib/api' + +import type { AvailabilityBadgeStatus } from './lib/status' + +export type AvailabilityRecord = { + created_at: number + use_time: number + ok: boolean +} + +export type AvailabilityGroup = { + group: string + records: AvailabilityRecord[] + success_rate: number + avg_use_time: number + status: AvailabilityBadgeStatus + total: number + success_count: number +} + +export type AvailabilityResponse = { + groups: AvailabilityGroup[] +} + +export async function getExtensionsAvailability(): Promise { + const res = await api.get('/api/extensions/availability') + if (!res.data?.success) { + throw new Error(res.data?.message || 'Failed to load availability') + } + return (res.data.data || { groups: [] }) as AvailabilityResponse +} diff --git a/web/default/src/features/extensions/availability/components/availability-group-card.tsx b/web/default/src/features/extensions/availability/components/availability-group-card.tsx new file mode 100644 index 000000000000..404bd43a59c7 --- /dev/null +++ b/web/default/src/features/extensions/availability/components/availability-group-card.tsx @@ -0,0 +1,106 @@ +/* +Copyright (C) 2023-2026 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, + 70|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 . + +For commercial licensing, please contact support@quantumnous.com +*/ +import { useTranslation } from 'react-i18next' + +import { StatusBadge } from '@/components/status-badge' +import { + Card, + CardContent, + CardHeader, + CardTitle, +} from '@/components/ui/card' + +import type { AvailabilityGroup } from '../api' +import { + formatSuccessRatePercent, + formatUseTimeSeconds, +} from '../lib/status' +import { HeartbeatBars } from './heartbeat-bars' + +type AvailabilityGroupCardProps = { + group: AvailabilityGroup + refreshHint?: string +} + +function badgeForStatus(status: AvailabilityGroup['status']): { + labelKey: string + variant: 'success' | 'warning' | 'danger' +} { + if (status === 'warn') { + return { labelKey: 'Warning', variant: 'warning' } + } + if (status === 'error') { + return { labelKey: 'Abnormal', variant: 'danger' } + } + return { labelKey: 'Normal', variant: 'success' } +} + +export function AvailabilityGroupCard(props: AvailabilityGroupCardProps) { + const { t } = useTranslation() + const badge = badgeForStatus(props.group.status) + + return ( + + +
+ + {props.group.group} + +

+ {t('Recent {{count}} records', { + count: props.group.total, + })} + {props.refreshHint ? ` · ${props.refreshHint}` : null} +

+
+ +
+ +
+
+

+ {t('Avg latency')} +

+

+ {props.group.success_count > 0 + ? formatUseTimeSeconds(props.group.avg_use_time) + : '—'} +

+
+
+

+ {t('Availability')} +

+

+ {formatSuccessRatePercent( + props.group.success_rate, + props.group.total + )} +

+
+
+ +
+
+ ) +} diff --git a/web/default/src/features/extensions/availability/components/heartbeat-bars.tsx b/web/default/src/features/extensions/availability/components/heartbeat-bars.tsx new file mode 100644 index 000000000000..c89641bdbdfa --- /dev/null +++ b/web/default/src/features/extensions/availability/components/heartbeat-bars.tsx @@ -0,0 +1,84 @@ +/* +Copyright (C) 2023-2026 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, + 70|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 . + +For commercial licensing, please contact support@quantumnous.com +*/ +import { useTranslation } from 'react-i18next' + +import { cn } from '@/lib/utils' + +import type { AvailabilityRecord } from '../api' +import { formatUseTimeSeconds } from '../lib/status' + +type HeartbeatBarsProps = { + records: AvailabilityRecord[] +} + +const MIN_HEIGHT_PCT = 18 +const MAX_HEIGHT_PCT = 100 +const FAIL_HEIGHT_PCT = 28 + +export function HeartbeatBars(props: HeartbeatBarsProps) { + const { t } = useTranslation() + const maxUseTime = props.records.reduce((max, record) => { + if (!record.ok) return max + return Math.max(max, record.use_time) + }, 0) + + return ( +
+
+ {props.records.length === 0 ? ( +

+ {t('No recent requests for this group.')} +

+ ) : ( + props.records.map((record) => { + let heightPct = FAIL_HEIGHT_PCT + if (record.ok) { + if (maxUseTime <= 0) { + heightPct = MIN_HEIGHT_PCT + } else { + heightPct = + MIN_HEIGHT_PCT + + (record.use_time / maxUseTime) * + (MAX_HEIGHT_PCT - MIN_HEIGHT_PCT) + } + } + const title = record.ok + ? formatUseTimeSeconds(record.use_time) + : t('Failed') + return ( +
+ ) + }) + )} +
+
+ {t('Past')} + {t('Now')} +
+
+ ) +} diff --git a/web/default/src/features/extensions/availability/hooks/use-availability.ts b/web/default/src/features/extensions/availability/hooks/use-availability.ts new file mode 100644 index 000000000000..df60bc38eaaa --- /dev/null +++ b/web/default/src/features/extensions/availability/hooks/use-availability.ts @@ -0,0 +1,67 @@ +/* +Copyright (C) 2023-2026 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 . + +For commercial licensing, please contact support@quantumnous.com +*/ +import { useQuery } from '@tanstack/react-query' + +import { useStatus } from '@/hooks/use-status' + +import { getExtensionsAvailability } from '../api' + +const DEFAULT_REFRESH_SECONDS = 10 +const MIN_REFRESH_SECONDS = 5 +const MAX_REFRESH_SECONDS = 3600 + +export function resolveAvailabilityRefreshSeconds( + raw: unknown +): number { + const value = + typeof raw === 'number' + ? raw + : typeof raw === 'string' + ? Number(raw) + : NaN + if (!Number.isFinite(value)) { + return DEFAULT_REFRESH_SECONDS + } + const seconds = Math.trunc(value) + if (seconds < MIN_REFRESH_SECONDS) { + return MIN_REFRESH_SECONDS + } + if (seconds > MAX_REFRESH_SECONDS) { + return MAX_REFRESH_SECONDS + } + return seconds +} + +export function useAvailability() { + const { status } = useStatus() + const refreshSeconds = resolveAvailabilityRefreshSeconds( + status?.availability_monitor_refresh_interval ?? + status?.data?.availability_monitor_refresh_interval + ) + const refreshMs = refreshSeconds * 1000 + + const query = useQuery({ + queryKey: ['extensions-availability'], + queryFn: getExtensionsAvailability, + refetchInterval: refreshMs, + staleTime: refreshMs / 2, + }) + + return { ...query, refreshSeconds } +} diff --git a/web/default/src/features/extensions/availability/index.tsx b/web/default/src/features/extensions/availability/index.tsx new file mode 100644 index 000000000000..126eae77f626 --- /dev/null +++ b/web/default/src/features/extensions/availability/index.tsx @@ -0,0 +1,88 @@ +/* +Copyright (C) 2023-2026 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, + 70|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 . + +For commercial licensing, please contact support@quantumnous.com +*/ +import { Loader2 } from 'lucide-react' +import { useTranslation } from 'react-i18next' + +import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert' +import { SectionPageLayout } from '@/components/layout' + +import { AvailabilityGroupCard } from './components/availability-group-card' +import { useAvailability } from './hooks/use-availability' + +export function AvailabilityMonitorPage() { + const { t } = useTranslation() + const query = useAvailability() + + return ( + + + {t('Availability Monitor')} + + +
+

+ {t( + 'Request health by billing group for the latest 100 consume/error logs. Green bars show latency; red bars are failures. Badge uses overall success rate (≥95% normal, ≥80% warning, below 80% abnormal).' + )} +

+ + {query.isPending ? ( +
+ + + {t('Loading...')} + +
+ ) : null} + + {query.isError ? ( + + {t('Unable to load availability')} + + {query.error instanceof Error + ? query.error.message + : t('Failed to load availability')} + + + ) : null} + + {query.data ? ( +
+ {query.data.groups.map((group) => ( + + ))} +
+ ) : null} + + {query.data && query.data.groups.length === 0 ? ( +

+ {t('No billing groups configured.')} +

+ ) : null} +
+
+
+ ) +} diff --git a/web/default/src/features/extensions/availability/lib/status.test.ts b/web/default/src/features/extensions/availability/lib/status.test.ts new file mode 100644 index 000000000000..2d75a7b0410f --- /dev/null +++ b/web/default/src/features/extensions/availability/lib/status.test.ts @@ -0,0 +1,32 @@ +/* +Copyright (C) 2023-2026 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 . + +For commercial licensing, please contact support@quantumnous.com +*/ +import assert from 'node:assert/strict' +import { describe, test } from 'node:test' + +import { availabilityStatusFromSuccessRate } from './status' + +describe('availabilityStatusFromSuccessRate', () => { + test('maps thresholds', () => { + assert.equal(availabilityStatusFromSuccessRate(1, 0), 'ok') + assert.equal(availabilityStatusFromSuccessRate(0.95, 100), 'ok') + assert.equal(availabilityStatusFromSuccessRate(0.949, 100), 'warn') + assert.equal(availabilityStatusFromSuccessRate(0.8, 100), 'warn') + assert.equal(availabilityStatusFromSuccessRate(0.799, 100), 'error') + }) +}) diff --git a/web/default/src/features/extensions/availability/lib/status.ts b/web/default/src/features/extensions/availability/lib/status.ts new file mode 100644 index 000000000000..12f002d4e4f9 --- /dev/null +++ b/web/default/src/features/extensions/availability/lib/status.ts @@ -0,0 +1,40 @@ +/* +Copyright (C) 2023-2026 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, + 70|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 . + +For commercial licensing, please contact support@quantumnous.com +*/ +export type AvailabilityBadgeStatus = 'ok' | 'warn' | 'error' + +export function availabilityStatusFromSuccessRate( + successRate: number, + total: number +): AvailabilityBadgeStatus { + if (total <= 0) return 'ok' + if (successRate >= 0.95) return 'ok' + if (successRate >= 0.8) return 'warn' + return 'error' +} + +export function formatUseTimeSeconds(seconds: number): string { + if (!Number.isFinite(seconds) || seconds <= 0) return '<1s' + if (Number.isInteger(seconds)) return `${seconds}s` + return `${seconds.toFixed(1)}s` +} + +export function formatSuccessRatePercent(rate: number, total: number): string { + if (total <= 0) return '—' + return `${(rate * 100).toFixed(2)}%` +} diff --git a/web/default/src/features/extensions/lottery/api.ts b/web/default/src/features/extensions/lottery/api.ts new file mode 100644 index 000000000000..b31a69fbd01a --- /dev/null +++ b/web/default/src/features/extensions/lottery/api.ts @@ -0,0 +1,51 @@ +/* +Copyright (C) 2023-2026 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 . + +For commercial licensing, please contact support@quantumnous.com +*/ +import { api } from '@/lib/api' + +import type { LotteryDrawResult, LotteryStatus } from './types' + +type ApiResponse = { + success: boolean + message?: string + data?: T +} + +export async function getLotteryStatus(): Promise { + const res = await api.get('/api/user/lottery') + const body = res.data as ApiResponse + if (!body.success || !body.data) { + throw new Error(body.message || 'Failed to load lottery status') + } + return body.data +} + +export async function drawLottery( + betUsd: number, + turnstileToken?: string +): Promise { + const url = turnstileToken + ? `/api/user/lottery?turnstile=${encodeURIComponent(turnstileToken)}` + : '/api/user/lottery' + const res = await api.post(url, { bet_usd: betUsd }) + const body = res.data as ApiResponse + if (!body.success || !body.data) { + throw new Error(body.message || 'Lottery draw failed') + } + return body.data +} diff --git a/web/default/src/features/extensions/lottery/components/lucky-prize-board.tsx b/web/default/src/features/extensions/lottery/components/lucky-prize-board.tsx new file mode 100644 index 000000000000..c8a9581e8695 --- /dev/null +++ b/web/default/src/features/extensions/lottery/components/lucky-prize-board.tsx @@ -0,0 +1,256 @@ +/* +Copyright (C) 2023-2026 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 . + +For commercial licensing, please contact support@quantumnous.com +*/ +import { Gift, Sparkles, Star } from 'lucide-react' +import { + forwardRef, + useImperativeHandle, + useMemo, +} from 'react' +import { useTranslation } from 'react-i18next' + +import { cn } from '@/lib/utils' + +import type { MarqueePhase } from '../hooks/use-marquee-spin' +import type { PrizeGridSlot } from '../lib/grid-layout' +import type { LotterySymbol } from '../types' + +export type LuckyPrizeBoardHandle = { + /** no-op kept for API compatibility; parent drives animation via props */ + play: () => void + stop: (index: number) => void +} + +type LuckyPrizeBoardProps = { + symbols: LotterySymbol[] + rows: number + cols: number + slots: PrizeGridSlot[] + crazyThursday: boolean + canDraw: boolean + drawing: boolean + /** Active board cell index for marquee highlight. */ + activeIndex: number | null + blinkOn: boolean + phase: MarqueePhase + onRequestStart: () => void +} + +function toneVisual(tone: LotterySymbol['tone']) { + if (tone === 'jackpot') { + return { + cell: 'from-[#FFF7D6] via-[#FBBF24] to-[#D97706] text-amber-950', + icon: , + iconBg: 'bg-amber-700/30', + } + } + if (tone === 'win') { + return { + cell: 'from-[#FFFBEB] via-[#FDE68A] to-[#F59E0B] text-amber-950', + icon: , + iconBg: 'bg-amber-900/10', + } + } + if (tone === 'lose') { + return { + cell: 'from-[#FFF1F2] via-[#FECDD3] to-[#FB7185] text-rose-950', + icon: , + iconBg: 'bg-rose-900/10', + } + } + return { + cell: 'from-[#FAFAFA] via-[#F4F4F5] to-[#D4D4D8] text-zinc-800', + icon: , + iconBg: 'bg-zinc-900/10', + } +} + +export const LuckyPrizeBoard = forwardRef< + LuckyPrizeBoardHandle, + LuckyPrizeBoardProps +>(function LuckyPrizeBoard(props, ref) { + const { t } = useTranslation() + + useImperativeHandle(ref, () => ({ + play: () => {}, + stop: () => {}, + })) + + const hasCenter = props.rows >= 3 && props.cols >= 3 + const selected = + props.activeIndex !== null && + (props.phase === 'running' || + props.phase === 'blinking' || + props.phase === 'done') && + (props.phase !== 'blinking' || props.blinkOn) + + const centerLabel = useMemo(() => { + if (props.drawing || props.phase === 'running') return t('SPINNING') + if (!props.canDraw) return t('Already spun today') + return t('SPIN') + }, [props.canDraw, props.drawing, props.phase, t]) + + return ( +
+
+ +
+
+
+ {Array.from({ length: 13 }).map((_, i) => ( + + ))} +
+ +
+ {props.slots.map((slot) => { + const symbol = props.symbols[slot.prizeIndex] + if (!symbol) return null + const visual = toneVisual(symbol.tone) + const isActive = + selected && props.activeIndex === slot.cellIndex + + return ( +
+
+
+ {visual.icon} +
+ + {symbol.name} + + + {symbol.label} + +
+
+ ) + })} + + {hasCenter ? ( + + ) : null} +
+ +

+ {props.crazyThursday + ? t('Crazy Thursday!') + : t('Lucky Slot Lottery')} +

+
+
+
+ ) +}) diff --git a/web/default/src/features/extensions/lottery/components/prize-result-dialog.tsx b/web/default/src/features/extensions/lottery/components/prize-result-dialog.tsx new file mode 100644 index 000000000000..2e648b6c5482 --- /dev/null +++ b/web/default/src/features/extensions/lottery/components/prize-result-dialog.tsx @@ -0,0 +1,96 @@ +/* +Copyright (C) 2023-2026 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 . + +For commercial licensing, please contact support@quantumnous.com +*/ +import { useTranslation } from 'react-i18next' + +import { Button } from '@/components/ui/button' +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog' +import { cn } from '@/lib/utils' + +import type { LotteryDrawResult } from '../types' + +type PrizeResultDialogProps = { + open: boolean + result: LotteryDrawResult | null + onOpenChange: (open: boolean) => void +} + +export function PrizeResultDialog(props: PrizeResultDialogProps) { + const { t } = useTranslation() + const result = props.result + if (!result) return null + + const positive = result.usd_delta > 0 + const negative = result.usd_delta < 0 + const amountText = + result.usd_delta > 0 + ? `+$${result.usd_delta.toFixed(4)}` + : `$${result.usd_delta.toFixed(4)}` + + return ( + + + + + {positive ? t('Congratulations!') : t('Result')} + + + {t('You got {{name}} ({{delta}})', { + name: result.prize_name, + delta: amountText, + })} + + + +
+
{result.prize_name}
+
+ {amountText} +
+ {result.is_pity ? ( +
{t('Pity prize triggered')}
+ ) : null} +
+ + + + +
+
+ ) +} diff --git a/web/default/src/features/extensions/lottery/hooks/use-marquee-spin.ts b/web/default/src/features/extensions/lottery/hooks/use-marquee-spin.ts new file mode 100644 index 000000000000..53daaa1a4443 --- /dev/null +++ b/web/default/src/features/extensions/lottery/hooks/use-marquee-spin.ts @@ -0,0 +1,206 @@ +/* +Copyright (C) 2023-2026 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 . + +For commercial licensing, please contact support@quantumnous.com +*/ +import { useCallback, useRef, useState } from 'react' + +export type MarqueePhase = 'idle' | 'running' | 'blinking' | 'done' + +type UseMarqueeSpinOptions = { + cellCount: number +} + +type StopResolver = { + resolve: () => void + reject: (err: Error) => void +} + +/** + * Marquee controller: + * - start(): begin racing the orange highlight immediately + * - stopAt(i): decelerate and land on board cell i, then blink + */ +export function useMarqueeSpin(options: UseMarqueeSpinOptions) { + const [phase, setPhase] = useState('idle') + const [activeIndex, setActiveIndex] = useState(null) + const [blinkOn, setBlinkOn] = useState(true) + + const timersRef = useRef([]) + const runningRef = useRef(false) + const stoppingRef = useRef(false) + const activeIndexRef = useRef(0) + const cellCountRef = useRef(Math.max(1, options.cellCount)) + const stopResolverRef = useRef(null) + const remainingStepsRef = useRef(null) + + cellCountRef.current = Math.max(1, options.cellCount) + + const clearTimers = useCallback(() => { + for (const id of timersRef.current) window.clearTimeout(id) + timersRef.current = [] + }, []) + + const reset = useCallback(() => { + clearTimers() + runningRef.current = false + stoppingRef.current = false + remainingStepsRef.current = null + if (stopResolverRef.current) { + stopResolverRef.current.resolve() + stopResolverRef.current = null + } + setPhase('idle') + setActiveIndex(null) + setBlinkOn(true) + }, [clearTimers]) + + const schedule = useCallback((fn: () => void, ms: number) => { + const id = window.setTimeout(fn, ms) + timersRef.current.push(id) + }, []) + + const blinkThenFinish = useCallback(() => { + setPhase('blinking') + let blinkCount = 0 + const blink = () => { + if (!runningRef.current) { + stopResolverRef.current?.resolve() + stopResolverRef.current = null + return + } + blinkCount += 1 + setBlinkOn((v) => !v) + if (blinkCount >= 8) { + setBlinkOn(true) + setPhase('done') + runningRef.current = false + stoppingRef.current = false + stopResolverRef.current?.resolve() + stopResolverRef.current = null + return + } + schedule(blink, 140) + } + schedule(blink, 120) + }, [schedule]) + + const tick = useCallback(() => { + if (!runningRef.current) return + + const count = cellCountRef.current + const next = (activeIndexRef.current + 1) % count + activeIndexRef.current = next + setActiveIndex(next) + + // Landing phase: count down remaining steps with easing + if (stoppingRef.current && remainingStepsRef.current !== null) { + remainingStepsRef.current -= 1 + if (remainingStepsRef.current <= 0) { + blinkThenFinish() + return + } + const left = remainingStepsRef.current + const total = Math.max(left, 1) + // Ease-out: slower as we approach the end + const t = 1 - left / (left + 8) + const delay = 45 + t * t * 280 + schedule(tick, delay) + return + } + + // Free-run: snappy start speed with slight jitter + const delay = 22 + Math.random() * 12 + schedule(tick, delay) + }, [blinkThenFinish, schedule]) + + const start = useCallback(() => { + clearTimers() + if (stopResolverRef.current) { + stopResolverRef.current.resolve() + stopResolverRef.current = null + } + runningRef.current = true + stoppingRef.current = false + remainingStepsRef.current = null + setPhase('running') + setBlinkOn(true) + // Kick off from a random cell so it feels alive immediately + const count = cellCountRef.current + const startAt = Math.floor(Math.random() * count) + activeIndexRef.current = startAt + setActiveIndex(startAt) + schedule(tick, 16) + }, [clearTimers, schedule, tick]) + + const stopAt = useCallback( + (targetIndex: number) => { + const count = cellCountRef.current + const safeIndex = ((targetIndex % count) + count) % count + + if (!runningRef.current) { + // Not spinning (e.g. reduced path) — jump + blink + runningRef.current = true + activeIndexRef.current = safeIndex + setActiveIndex(safeIndex) + setPhase('running') + return new Promise((resolve, reject) => { + stopResolverRef.current = { resolve, reject } + stoppingRef.current = true + // At least 1.5 laps then land + const laps = 2 + const cur = activeIndexRef.current + const dist = laps * count + ((safeIndex - cur + count) % count) + remainingStepsRef.current = Math.max(count, dist) + schedule(tick, 40) + }) + } + + return new Promise((resolve, reject) => { + stopResolverRef.current = { resolve, reject } + stoppingRef.current = true + const cur = activeIndexRef.current + // Extra laps after stop signal so deceleration is visible + const laps = 2 + Math.floor(Math.random() * 2) + const dist = laps * count + ((safeIndex - cur + count) % count) + remainingStepsRef.current = Math.max(count + 4, dist) + }) + }, + [schedule, tick] + ) + + /** Convenience: start + stopAt in one call (waits for API beforehand). */ + const spinTo = useCallback( + async (targetIndex: number) => { + start() + // Brief free-spin so the ring is clearly moving before we aim + await new Promise((r) => schedule(r, 600)) + await stopAt(targetIndex) + }, + [schedule, start, stopAt] + ) + + return { + phase, + activeIndex, + blinkOn, + spinning: phase === 'running' || phase === 'blinking', + start, + stopAt, + spinTo, + reset, + } +} diff --git a/web/default/src/features/extensions/lottery/index.tsx b/web/default/src/features/extensions/lottery/index.tsx new file mode 100644 index 000000000000..b9e93fecac02 --- /dev/null +++ b/web/default/src/features/extensions/lottery/index.tsx @@ -0,0 +1,388 @@ +/* +Copyright (C) 2023-2026 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 . + +For commercial licensing, please contact support@quantumnous.com +*/ +import { useQuery, useQueryClient } from '@tanstack/react-query' +import { Loader2 } from 'lucide-react' +import { useEffect, useMemo, useState } from 'react' +import { useTranslation } from 'react-i18next' +import { toast } from 'sonner' + +import { SectionPageLayout } from '@/components/layout' +import { Turnstile } from '@/components/turnstile' +import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { Label } from '@/components/ui/label' +import { Switch } from '@/components/ui/switch' +import { useStatus } from '@/hooks/use-status' +import { cn } from '@/lib/utils' + +import { drawLottery, getLotteryStatus } from './api' +import { LuckyPrizeBoard } from './components/lucky-prize-board' +import { PrizeResultDialog } from './components/prize-result-dialog' +import { useMarqueeSpin } from './hooks/use-marquee-spin' +import { + buildWeightedPrizeSlots, + pickCellForPrize, +} from './lib/grid-layout' +import { buildBetSymbols, buildFreeSymbols } from './lib/symbols' +import type { LotteryDrawResult } from './types' + +export function LotteryPage() { + const { t } = useTranslation() + const { status } = useStatus() + const queryClient = useQueryClient() + const turnstileEnabled = !!( + status?.turnstile_check && status?.turnstile_site_key + ) + const turnstileSiteKey = status?.turnstile_site_key || '' + + const [useBet, setUseBet] = useState(false) + const [betUsd, setBetUsd] = useState(0.01) + const [turnstileToken, setTurnstileToken] = useState('') + const [turnstileKey, setTurnstileKey] = useState(0) + const [drawing, setDrawing] = useState(false) + const [result, setResult] = useState(null) + const [resultOpen, setResultOpen] = useState(false) + + const query = useQuery({ + queryKey: ['lottery-status'], + queryFn: getLotteryStatus, + }) + + const data = query.data + const isCrazyThursday = !!data?.is_crazy_thursday + const meetsRedemptionRequirement = !!data?.meets_redemption_requirement + const canDraw = !!data?.can_draw + const symbols = useMemo(() => { + if (!data) return [] + return useBet + ? buildBetSymbols(data.bet_prizes || []) + : buildFreeSymbols(data.free_prizes || []) + }, [data, useBet]) + + const board = useMemo( + () => buildWeightedPrizeSlots(symbols.map((s) => s.weight)), + [symbols] + ) + + const marquee = useMarqueeSpin({ + cellCount: board.slots.length || 1, + }) + + const settledCellIndex = useMemo(() => { + if (!result) return null + const match = board.slots.find((s) => s.prizeIndex === result.prize_index) + return match?.cellIndex ?? null + }, [board.slots, result]) + + useEffect(() => { + if (!data) return + const maxBet = Math.min(data.max_bet_usd, data.user_usd) + const minBet = Math.min(data.min_bet_usd, maxBet) + setBetUsd((prev) => { + if (prev < minBet) return minBet + if (prev > maxBet) return Math.max(minBet, maxBet) + return prev + }) + }, [data]) + + useEffect(() => { + if (!data?.today_draw) return + setResult({ + prize_index: data.today_draw.prize_index, + prize_name: data.today_draw.prize_name, + quota_delta: data.today_draw.quota_delta, + usd_delta: data.today_draw.usd_delta, + bet_quota: data.today_draw.bet_quota, + bet_usd: data.today_draw.bet_usd, + is_thanks: data.today_draw.is_thanks, + is_pity: data.today_draw.is_pity, + is_thursday: data.today_draw.is_thursday, + remaining_pool: 0, + remaining_pool_usd: 0, + draw_date: data.today_draw.draw_date, + }) + }, [data]) + + const maxBetAllowed = data ? Math.min(data.max_bet_usd, data.user_usd) : 0 + + let spinLabel = t('SPIN') + if (drawing) { + spinLabel = t('SPINNING') + } else if (data && !meetsRedemptionRequirement) { + spinLabel = t('Redeem code required') + } else if (!canDraw) { + spinLabel = t('Already spun today') + } + + async function runDraw() { + if (!data || !canDraw || drawing) return + if (!meetsRedemptionRequirement) { + toast.error( + t( + 'Please redeem a code before playing. Crazy Thursday does not require this.' + ) + ) + return + } + if (turnstileEnabled && !turnstileToken) { + toast.error(t('Please complete the human verification first')) + return + } + if (useBet) { + if (betUsd < data.min_bet_usd || betUsd > data.max_bet_usd) { + toast.error(t('Bet amount is out of range')) + return + } + if (betUsd > data.user_usd) { + toast.error(t('Bet cannot exceed your current balance')) + return + } + } + + setDrawing(true) + setResult(null) + setResultOpen(false) + // Start the orange marquee immediately so users see motion while waiting API + marquee.start() + + try { + const drawResult = await drawLottery( + useBet ? betUsd : 0, + turnstileToken || undefined + ) + const landCell = pickCellForPrize(board.slots, drawResult.prize_index) + await marquee.stopAt(landCell) + setResult(drawResult) + setResultOpen(true) + setTurnstileToken('') + setTurnstileKey((k) => k + 1) + await queryClient.invalidateQueries({ queryKey: ['lottery-status'] }) + } catch (error) { + marquee.reset() + toast.error( + error instanceof Error ? error.message : t('Lottery draw failed') + ) + setTurnstileToken('') + setTurnstileKey((k) => k + 1) + } finally { + setDrawing(false) + } + } + + return ( + + {t('Lucky Slot')} + + {query.isPending ? ( +
+ + + {t('Loading...')} + +
+ ) : null} + + {query.isError ? ( + + {t('Unable to load lottery')} + + {query.error instanceof Error + ? query.error.message + : t('Failed to load lottery status')} + + + ) : null} + + {data ? ( +
+ {isCrazyThursday ? ( + +
+
+
+ + + 🔥 + + + {t('Crazy Thursday!')} + + + 🔥 + + + + {t( + 'Prize pool and free prize amounts are doubled today. V me 50!' + )} + +
+ + ) : null} + + {data.require_redemption && !meetsRedemptionRequirement ? ( + + {t('Redeem code required')} + + {t( + 'Please redeem a code before playing. Crazy Thursday does not require this.' + )} + + + ) : null} + +
+ + + +
+ + void runDraw()} + /> + +
+
+
+ +

+ {t( + 'Optional. Max net win is 2x bet; you may also lose balance.' + )} +

+
+ +
+ + {useBet ? ( +
+ + setBetUsd(Number(e.target.value) || 0)} + /> +

+ {t('Allowed: ${{min}} – ${{max}}', { + min: data.min_bet_usd, + max: maxBetAllowed, + })} +

+
+ ) : null} + + {turnstileEnabled ? ( +
+ setTurnstileToken('')} + /> +
+ ) : null} + + +
+ + +
+ ) : null} + + + ) +} + +function StatCard(props: { label: string; value: string }) { + return ( +
+
{props.label}
+
+ {props.value} +
+
+ ) +} diff --git a/web/default/src/features/extensions/lottery/lib/grid-layout.ts b/web/default/src/features/extensions/lottery/lib/grid-layout.ts new file mode 100644 index 000000000000..6a0955bee3fa --- /dev/null +++ b/web/default/src/features/extensions/lottery/lib/grid-layout.ts @@ -0,0 +1,186 @@ +/* +Copyright (C) 2023-2026 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 . + +For commercial licensing, please contact support@quantumnous.com +*/ + +export type GridCellPos = { row: number; col: number } + +export function perimeter(rows: number, cols: number): number { + if (rows <= 0 || cols <= 0) return 0 + if (rows === 1) return cols + if (cols === 1) return rows + return 2 * (rows + cols) - 4 +} + +/** Smallest ring with perimeter >= `count` (prefer wider boards). */ +export function fitGridSize(count: number): { rows: number; cols: number } { + const n = Math.max(1, count) + let cols = 3 + let rows = 3 + while (perimeter(rows, cols) < n) { + if (rows < cols) rows += 1 + else cols += 1 + } + return { rows, cols } +} + +/** Clockwise border path starting at top-left. */ +export function borderPath(rows: number, cols: number): GridCellPos[] { + if (rows <= 0 || cols <= 0) return [] + if (rows === 1) { + return Array.from({ length: cols }, (_, col) => ({ row: 0, col })) + } + if (cols === 1) { + return Array.from({ length: rows }, (_, row) => ({ row, col: 0 })) + } + + const path: GridCellPos[] = [] + for (let col = 0; col < cols; col++) path.push({ row: 0, col }) + for (let row = 1; row < rows; row++) path.push({ row, col: cols - 1 }) + for (let col = cols - 2; col >= 0; col--) path.push({ row: rows - 1, col }) + for (let row = rows - 2; row >= 1; row--) path.push({ row, col: 0 }) + return path +} + +export type PrizeGridSlot = { + /** Board cell index along the marquee path. */ + cellIndex: number + /** Index into the original prize list. */ + prizeIndex: number + row: number + col: number +} + +/** Prefer at least a 5×4 ring (14 cells) so the board feels fuller. */ +const MIN_BOARD_CELLS = 14 + +/** + * Expand prize indices by relative weight, then pad/trim to an exact + * rectangular perimeter so the ring has no empty cells. + */ +export function expandPrizeIndicesByWeight( + weights: number[], + minCells = MIN_BOARD_CELLS +): number[] { + const n = weights.length + if (n === 0) return [] + + const safe = weights.map((w) => Math.max(1, Math.round(Number(w) || 1))) + const weightSum = safe.reduce((a, b) => a + b, 0) + + const { rows, cols } = fitGridSize(Math.max(minCells, n)) + const target = perimeter(rows, cols) + + // Largest-remainder method: counts ∝ weights, every prize ≥ 1 + const exact = safe.map((w) => (w / weightSum) * target) + const counts = exact.map((v) => Math.max(1, Math.floor(v))) + let sum = counts.reduce((a, b) => a + b, 0) + + while (sum > target) { + let best = -1 + let bestCount = 1 + for (let i = 0; i < n; i++) { + if (counts[i] > bestCount) { + bestCount = counts[i] + best = i + } + } + if (best < 0) break + counts[best] -= 1 + sum -= 1 + } + + if (sum < target) { + const order = exact + .map((v, i) => ({ i, frac: v - Math.floor(v) })) + .sort((a, b) => b.frac - a.frac) + let k = 0 + while (sum < target) { + counts[order[k % n].i] += 1 + sum += 1 + k += 1 + } + } + + return interleaveByCounts(counts) +} + +function interleaveByCounts(counts: number[]): number[] { + const remaining = [...counts] + const total = remaining.reduce((a, b) => a + b, 0) + const out: number[] = [] + while (out.length < total) { + let best = -1 + let bestScore = -Infinity + for (let i = 0; i < remaining.length; i++) { + if (remaining[i] <= 0) continue + const score = remaining[i] + (out[out.length - 1] === i ? -0.5 : 0) + if (score > bestScore) { + bestScore = score + best = i + } + } + if (best < 0) break + out.push(best) + remaining[best] -= 1 + } + return out +} + +/** Build a fully filled prize ring from prize weights. */ +export function buildWeightedPrizeSlots(weights: number[]): { + rows: number + cols: number + slots: PrizeGridSlot[] +} { + const prizeIndices = expandPrizeIndicesByWeight(weights) + const count = Math.max(1, prizeIndices.length) + const { rows, cols } = fitGridSize(count) + const path = borderPath(rows, cols) + const peri = path.length + let indices = prizeIndices + if (indices.length !== peri) { + indices = expandPrizeIndicesByWeight(weights, peri) + while (indices.length < peri) { + indices.push(indices.length % Math.max(1, weights.length)) + } + if (indices.length > peri) indices = indices.slice(0, peri) + } + + const slots: PrizeGridSlot[] = path.map((pos, cellIndex) => ({ + cellIndex, + prizeIndex: indices[cellIndex] ?? 0, + row: pos.row, + col: pos.col, + })) + + return { rows, cols, slots } +} + +/** Pick a board cell that shows the given prize (for landing animation). */ +export function pickCellForPrize( + slots: PrizeGridSlot[], + prizeIndex: number +): number { + const matches = slots + .filter((s) => s.prizeIndex === prizeIndex) + .map((s) => s.cellIndex) + if (matches.length === 0) { + return Math.max(0, Math.min(prizeIndex, slots.length - 1)) + } + return matches[Math.floor(Math.random() * matches.length)] +} diff --git a/web/default/src/features/extensions/lottery/lib/symbols.ts b/web/default/src/features/extensions/lottery/lib/symbols.ts new file mode 100644 index 000000000000..6930c5a0e7d1 --- /dev/null +++ b/web/default/src/features/extensions/lottery/lib/symbols.ts @@ -0,0 +1,69 @@ +/* +Copyright (C) 2023-2026 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 . + +For commercial licensing, please contact support@quantumnous.com +*/ +import type { + LotteryBetPrize, + LotteryFreePrize, + LotterySymbol, +} from '../types' + +function formatUsd(usd: number): string { + if (usd === 0) return '$0' + const digits = Math.abs(usd) < 0.1 ? 4 : 2 + return `$${usd.toFixed(digits)}` +} + +export function buildFreeSymbols(prizes: LotteryFreePrize[]): LotterySymbol[] { + const maxUsd = Math.max(0, ...prizes.map((p) => p.usd)) + return prizes.map((p, index) => { + let tone: LotterySymbol['tone'] = 'win' + if (p.is_thanks || p.usd === 0) tone = 'thanks' + else if (p.usd === maxUsd && maxUsd > 0) tone = 'jackpot' + return { + index, + name: p.name, + label: p.usd === 0 ? p.name : `+${formatUsd(p.usd)}`, + tone, + weight: p.weight, + } + }) +} + +export function buildBetSymbols(prizes: LotteryBetPrize[]): LotterySymbol[] { + return prizes.map((p, index) => { + let tone: LotterySymbol['tone'] = 'win' + if (p.is_thanks || p.multiplier === 0) tone = 'thanks' + else if (p.multiplier < 0) tone = 'lose' + else if (p.multiplier >= 2) tone = 'jackpot' + + let label = `${p.multiplier}×` + if (p.multiplier === 0) { + label = p.name + } else if (p.multiplier > 0) { + label = `×${p.multiplier}` + } + + return { + index, + name: p.name, + label, + tone, + weight: p.weight, + } + }) +} diff --git a/web/default/src/features/extensions/lottery/types.ts b/web/default/src/features/extensions/lottery/types.ts new file mode 100644 index 000000000000..d988717d4e6f --- /dev/null +++ b/web/default/src/features/extensions/lottery/types.ts @@ -0,0 +1,91 @@ +/* +Copyright (C) 2023-2026 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 . + +For commercial licensing, please contact support@quantumnous.com +*/ + +export type LotteryFreePrize = { + name: string + usd: number + weight: number + is_thanks: boolean +} + +export type LotteryBetPrize = { + name: string + multiplier: number + weight: number + is_thanks: boolean +} + +export type LotteryTodayDraw = { + prize_name: string + prize_index: number + quota_delta: number + usd_delta: number + bet_quota: number + bet_usd: number + is_thanks: boolean + is_pity: boolean + is_thursday: boolean + draw_date: string +} + +export type LotteryStatus = { + enabled: boolean + can_draw: boolean + /** Whether the user may play under the redemption-code gate. */ + meets_redemption_requirement: boolean + /** When true, non-Thursday draws require at least one redeemed code. */ + require_redemption: boolean + is_crazy_thursday: boolean + /** User-facing marketing pool (not the real payout cap). */ + display_daily_pool_usd: number + effective_display_daily_pool_usd: number + min_bet_usd: number + max_bet_usd: number + user_quota: number + user_usd: number + quota_per_unit: number + thanks_streak: number + pity_threshold: number + free_prizes: LotteryFreePrize[] + bet_prizes: LotteryBetPrize[] + today_draw: LotteryTodayDraw | null +} + +export type LotteryDrawResult = { + prize_index: number + prize_name: string + quota_delta: number + usd_delta: number + bet_quota: number + bet_usd: number + is_thanks: boolean + is_pity: boolean + is_thursday: boolean + remaining_pool: number + remaining_pool_usd: number + draw_date: string +} + +export type LotterySymbol = { + index: number + name: string + label: string + tone: 'thanks' | 'win' | 'lose' | 'jackpot' + weight: number +} diff --git a/web/default/src/features/home/components/home-html-frame.tsx b/web/default/src/features/home/components/home-html-frame.tsx new file mode 100644 index 000000000000..2c2b210cb60a --- /dev/null +++ b/web/default/src/features/home/components/home-html-frame.tsx @@ -0,0 +1,79 @@ +/* +Copyright (C) 2023-2026 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 . + +For commercial licensing, please contact support@quantumnous.com +*/ +import { useEffect, useState } from 'react' +import { useTranslation } from 'react-i18next' + +/** + * Renders admin-configured HomePageContent HTML in a sandboxed iframe. + * + * Isolated Shadow DOM + cloned app stylesheets fights self-contained pages + * (custom + +${html} +` +} diff --git a/web/default/src/features/home/components/sections/hero.tsx b/web/default/src/features/home/components/sections/hero.tsx index 4a08151a59fb..ae32a4834702 100644 --- a/web/default/src/features/home/components/sections/hero.tsx +++ b/web/default/src/features/home/components/sections/hero.tsx @@ -16,7 +16,6 @@ along with this program. If not, see . For commercial licensing, please contact support@quantumnous.com */ -import { CherryStudio } from '@lobehub/icons' import { Link } from '@tanstack/react-router' import { ArrowRight, BookOpen } from 'lucide-react' import { useTranslation } from 'react-i18next' @@ -192,7 +191,11 @@ export function Hero(props: HeroProps) { rel='noopener noreferrer' className='group border-border/40 bg-muted/15 text-foreground/80 hover:border-border hover:bg-muted/30 hover:text-foreground flex items-center gap-3 rounded-full border px-5 py-2.5 text-sm font-medium shadow-[0_1px_2.5px_rgba(0,0,0,0.01)] backdrop-blur-xs transition-all duration-300 hover:scale-[1.02]' > - + Cherry Studio Cherry Studio diff --git a/web/default/src/features/home/index.tsx b/web/default/src/features/home/index.tsx index c157d352bd0c..fb185399545a 100644 --- a/web/default/src/features/home/index.tsx +++ b/web/default/src/features/home/index.tsx @@ -27,6 +27,7 @@ import { isLikelyHtml } from '@/lib/content-format' import { useAuthStore } from '@/stores/auth-store' import { CTA, Features, Hero, HowItWorks, Stats } from './components' +import { HomeHtmlFrame } from './components/home-html-frame' import { useHomePageContent } from './hooks' export function Home() { @@ -52,11 +53,13 @@ export function Home() { } }, [i18n.language, resolvedTheme]) + const contentIsHtml = !!content && !isUrl && isLikelyHtml(content) + useEffect(() => { - if (isUrl) { + if (isUrl || contentIsHtml) { syncIframePreferences() } - }, [isUrl, syncIframePreferences]) + }, [contentIsHtml, isUrl, syncIframePreferences]) if (!isLoaded) { return ( @@ -92,16 +95,13 @@ export function Home() { ) } - const contentIsHtml = isLikelyHtml(content) - if (contentIsHtml) { return ( - ) diff --git a/web/default/src/features/keys/api.ts b/web/default/src/features/keys/api.ts index df3cc5ff74bc..25eb29533f6c 100644 --- a/web/default/src/features/keys/api.ts +++ b/web/default/src/features/keys/api.ts @@ -25,6 +25,7 @@ import type { GetApiKeysResponse, SearchApiKeysParams, ApiKeyFormData, + CreateApiKeyResult, } from './types' // ============================================================================ @@ -63,7 +64,7 @@ export async function getApiKey(id: number): Promise> { // Create a new API key export async function createApiKey( data: ApiKeyFormData -): Promise> { +): Promise> { const res = await api.post('/api/token/', data) return res.data } diff --git a/web/default/src/features/keys/components/api-keys-dialogs.tsx b/web/default/src/features/keys/components/api-keys-dialogs.tsx index ae45cdf90893..1a22be4ff66a 100644 --- a/web/default/src/features/keys/components/api-keys-dialogs.tsx +++ b/web/default/src/features/keys/components/api-keys-dialogs.tsx @@ -20,6 +20,7 @@ import { ApiKeysDeleteDialog } from './api-keys-delete-dialog' import { ApiKeysMutateDrawer } from './api-keys-mutate-drawer' import { useApiKeys } from './api-keys-provider' import { CCSwitchDialog } from './dialogs/cc-switch-dialog' +import { ConnectToolDialog } from './dialogs/connect-tool-dialog' export function ApiKeysDialogs() { const { open, setOpen, currentRow, resolvedKey } = useApiKeys() @@ -37,6 +38,10 @@ export function ApiKeysDialogs() { onOpenChange={(isOpen) => !isOpen && setOpen(null)} tokenKey={resolvedKey} /> + !isOpen && setOpen(null)} + /> ) } diff --git a/web/default/src/features/keys/components/api-keys-primary-buttons.tsx b/web/default/src/features/keys/components/api-keys-primary-buttons.tsx index da68dc28e9e8..abecc332a10e 100644 --- a/web/default/src/features/keys/components/api-keys-primary-buttons.tsx +++ b/web/default/src/features/keys/components/api-keys-primary-buttons.tsx @@ -16,7 +16,7 @@ along with this program. If not, see . For commercial licensing, please contact support@quantumnous.com */ -import { Plus } from 'lucide-react' +import { Plus, Sparkles } from 'lucide-react' import { useTranslation } from 'react-i18next' import { Button } from '@/components/ui/button' @@ -28,6 +28,14 @@ export function ApiKeysPrimaryButtons() { const { setOpen } = useApiKeys() return (
+ + + + } + > +
+ +
+ {ENDPOINT_TYPES.map((endpoint) => { + const enabled = + !isLoadingOptions && availableEndpointIds.includes(endpoint.id) + const selected = endpointId === endpoint.id + return ( + + ) + })} +
+ {isLoadingOptions && ( +

+ {t('Loading available providers...')} +

+ )} + {loadFailed && ( +

+ {t( + 'Could not load pricing data. Open the pricing page or refresh and try again.' + )} +

+ )} + {!isLoadingOptions && + !loadFailed && + availableEndpointIds.length === 0 && ( +

+ {t( + 'No provider types are available. Types unlock when pricing models match Anthropic / OpenAI for your groups — having channels alone is not enough.' + )} +

+ )} +
+ +
+ + +
+ +
+ +
+ +
+

+ {t( + 'A recommended model is selected automatically. You can change it.' + )} +

+
+ +
+ + setToolId(value as ConnectToolId)} + className='flex flex-col gap-2' + > +
+ + +
+
+ + +
+
+
+ + {manualHint && createdKey && ( +
+

+ {t( + 'If the app did not open, install the tool and use this API key manually:' + )} +

+ + {createdKey} + +
+ )} + + ) +} diff --git a/web/default/src/features/keys/lib/connect-tool.ts b/web/default/src/features/keys/lib/connect-tool.ts new file mode 100644 index 000000000000..311e01141f93 --- /dev/null +++ b/web/default/src/features/keys/lib/connect-tool.ts @@ -0,0 +1,295 @@ +/* +Copyright (C) 2023-2026 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 . + +For commercial licensing, please contact support@quantumnous.com +*/ +import type { PricingModel } from '@/features/pricing/types' + +export type EndpointTypeId = 'anthropic' | 'openai' +export type ConnectToolId = 'cc-switch' | 'cherry-studio' + +export type EndpointTypeConfig = { + id: EndpointTypeId + label: string + iconKey: string + vendorMatchers: string[] + modelMatchers: RegExp[] + preferPatterns: RegExp[] + /** + * Primary pricing endpoint keys used for group/model filtering via + * `enable_groups_by_endpoint` (channel native protocol, not secondary compat). + */ + groupEndpointKeys: string[] + ccSwitchApp: 'claude' | 'codex' +} + +export const ENDPOINT_TYPES: EndpointTypeConfig[] = [ + { + id: 'anthropic', + label: 'Anthropic', + iconKey: 'Anthropic', + vendorMatchers: ['anthropic', 'claude'], + modelMatchers: [/claude/i], + preferPatterns: [/sonnet/i, /opus/i, /haiku/i, /claude/i], + groupEndpointKeys: ['anthropic'], + ccSwitchApp: 'claude', + }, + { + id: 'openai', + label: 'OpenAI', + iconKey: 'OpenAI', + vendorMatchers: ['openai'], + modelMatchers: [/^(gpt-|o[1-9]|chatgpt-|codex)/i], + preferPatterns: [/codex/i, /gpt-4o/i, /gpt-4\.1/i, /gpt/i], + groupEndpointKeys: ['openai', 'openai-response', 'openai-response-compact'], + ccSwitchApp: 'codex', + }, +] + +export function getEndpointTypeConfig( + id: EndpointTypeId +): EndpointTypeConfig | undefined { + return ENDPOINT_TYPES.find((item) => item.id === id) +} + +function normalizeVendor(value: string | undefined | null): string { + return (value || '').trim().toLowerCase() +} + +function groupsForModelEndpoint( + model: PricingModel, + endpoint: EndpointTypeConfig +): string[] | null { + const byEndpoint = model.enable_groups_by_endpoint + if (!byEndpoint || endpoint.groupEndpointKeys.length === 0) return null + const groups = new Set() + let found = false + for (const key of endpoint.groupEndpointKeys) { + const list = byEndpoint[key] + if (!list || list.length === 0) continue + found = true + for (const group of list) groups.add(group) + } + return found ? [...groups] : [] +} + +/** Match by vendor name / model name heuristics for this provider type. */ +export function modelMatchesProviderHeuristic( + model: PricingModel, + endpoint: EndpointTypeConfig +): boolean { + const vendor = normalizeVendor(model.vendor_name) + if ( + vendor && + endpoint.vendorMatchers.some( + (matcher) => vendor === matcher || vendor.includes(matcher) + ) + ) { + return true + } + const modelName = model.model_name || '' + return endpoint.modelMatchers.some((pattern) => pattern.test(modelName)) +} + +/** + * A model is usable for a provider type when it is served on that primary + * endpoint (enable_groups_by_endpoint), and also looks like that provider + * (vendor/name). The heuristic avoids dumping every model on a busy OpenAI + * channel into the Codex picker. + */ +export function modelMatchesEndpoint( + model: PricingModel, + endpoint: EndpointTypeConfig +): boolean { + const byEndpointGroups = groupsForModelEndpoint(model, endpoint) + if (byEndpointGroups !== null) { + return ( + byEndpointGroups.length > 0 && + modelMatchesProviderHeuristic(model, endpoint) + ) + } + return modelMatchesProviderHeuristic(model, endpoint) +} + +export function filterModelsForEndpoint( + models: PricingModel[], + endpointId: EndpointTypeId +): PricingModel[] { + const endpoint = getEndpointTypeConfig(endpointId) + if (!endpoint) return [] + return models.filter((model) => modelMatchesEndpoint(model, endpoint)) +} + +function collectGroups( + groups: Iterable, + usable: Set, + restrictToUsable: boolean, + out: Set +) { + for (const group of groups) { + if (!group || group === 'auto') continue + if (group === 'all') { + if (restrictToUsable) { + for (const item of usable) { + if (item && item !== 'auto') out.add(item) + } + } + continue + } + if (!restrictToUsable || usable.has(group)) out.add(group) + } +} + +export function getGroupsForEndpoint( + models: PricingModel[], + endpointId: EndpointTypeId, + usableGroups: string[] +): string[] { + const endpoint = getEndpointTypeConfig(endpointId) + if (!endpoint) return [] + const usable = new Set(usableGroups) + const restrictToUsable = usable.size > 0 + const groups = new Set() + + let usedByEndpoint = false + for (const model of models) { + // Only count groups from models that belong to this provider type, + // so OpenAI groups are not derived from unrelated channel inventory. + if (!modelMatchesProviderHeuristic(model, endpoint)) continue + const byEndpointGroups = groupsForModelEndpoint(model, endpoint) + if (byEndpointGroups === null) continue + usedByEndpoint = true + collectGroups(byEndpointGroups, usable, restrictToUsable, groups) + } + if (usedByEndpoint) { + return [...groups].sort((a, b) => a.localeCompare(b)) + } + + // Legacy fallback: vendor/name match + union enable_groups + for (const model of filterModelsForEndpoint(models, endpointId)) { + collectGroups(model.enable_groups || [], usable, restrictToUsable, groups) + } + return [...groups].sort((a, b) => a.localeCompare(b)) +} + +export function filterModelsForGroup( + models: PricingModel[], + endpointId: EndpointTypeId, + group: string +): PricingModel[] { + const endpoint = getEndpointTypeConfig(endpointId) + if (!endpoint) return [] + + if (models.some((model) => model.enable_groups_by_endpoint)) { + return models.filter((model) => { + if (!modelMatchesProviderHeuristic(model, endpoint)) return false + const byEndpointGroups = groupsForModelEndpoint(model, endpoint) + if (byEndpointGroups === null) return false + return ( + byEndpointGroups.includes(group) || byEndpointGroups.includes('all') + ) + }) + } + + return filterModelsForEndpoint(models, endpointId).filter((model) => { + const groups = model.enable_groups || [] + return groups.includes(group) || groups.includes('all') + }) +} + +export function recommendModelName( + models: PricingModel[], + endpointId: EndpointTypeId +): string { + const endpoint = getEndpointTypeConfig(endpointId) + if (!endpoint || models.length === 0) return '' + for (const pattern of endpoint.preferPatterns) { + const hit = models.find((model) => pattern.test(model.model_name)) + if (hit) return hit.model_name + } + return models[0]?.model_name || '' +} + +export function getServerAddress(): string { + try { + const raw = localStorage.getItem('status') + if (raw) { + const status = JSON.parse(raw) as { server_address?: string } + if (status.server_address) return status.server_address + } + } catch { + /* empty */ + } + return window.location.origin +} + +function normalizeApiKey(apiKey: string): string { + const trimmed = apiKey.trim() + if (!trimmed) return '' + return trimmed.startsWith('sk-') ? trimmed : `sk-${trimmed}` +} + +export function buildCCSwitchImportURL(params: { + app: 'claude' | 'codex' + name: string + model: string + apiKey: string +}): string { + const serverAddress = getServerAddress() + const endpoint = + params.app === 'codex' ? `${serverAddress}/v1` : serverAddress + const search = new URLSearchParams() + search.set('resource', 'provider') + search.set('app', params.app) + search.set('name', params.name) + search.set('endpoint', endpoint) + search.set('apiKey', normalizeApiKey(params.apiKey)) + search.set('model', params.model) + search.set('homepage', serverAddress) + search.set('enabled', 'true') + return `ccswitch://v1/import?${search.toString()}` +} + +function toBase64(value: string): string { + const bytes = new TextEncoder().encode(value) + let binary = '' + for (const byte of bytes) { + binary += String.fromCharCode(byte) + } + return btoa(binary) +} + +export function buildCherryStudioImportURL(apiKey: string): string { + const serverAddress = getServerAddress() + const payload = { + id: 'new-api', + baseUrl: serverAddress, + apiKey: normalizeApiKey(apiKey), + } + const encoded = encodeURIComponent(toBase64(JSON.stringify(payload))) + return `cherrystudio://providers/api-keys?v=1&data=${encoded}` +} + +export function buildConnectTokenName( + endpointLabel: string, + group: string +): string { + const date = new Date() + const month = String(date.getMonth() + 1).padStart(2, '0') + const day = String(date.getDate()).padStart(2, '0') + const raw = `${endpointLabel} · ${group} · ${month}-${day}` + return raw.length > 50 ? raw.slice(0, 50) : raw +} diff --git a/web/default/src/features/keys/types.ts b/web/default/src/features/keys/types.ts index 1583e6497df7..ec5bc132612c 100644 --- a/web/default/src/features/keys/types.ts +++ b/web/default/src/features/keys/types.ts @@ -104,3 +104,10 @@ export type ApiKeysDialogType = | 'delete' | 'batch-delete' | 'cc-switch' + | 'connect-tool' + +export type CreateApiKeyResult = { + id: number + key: string + name: string +} diff --git a/web/default/src/features/pricing/types.ts b/web/default/src/features/pricing/types.ts index 8a0e244d5d09..97987b19277d 100644 --- a/web/default/src/features/pricing/types.ts +++ b/web/default/src/features/pricing/types.ts @@ -46,6 +46,11 @@ export type PricingModel = { audio_ratio?: number | null audio_completion_ratio?: number | null enable_groups: string[] + /** + * Groups keyed by the channel's primary endpoint type (e.g. anthropic, openai). + * Preserves endpoint×group pairing; prefer this over enable_groups when filtering by protocol. + */ + enable_groups_by_endpoint?: Record tags?: string supported_endpoint_types?: string[] key?: string diff --git a/web/default/src/features/redemption-codes/components/redemptions-mutate-drawer.tsx b/web/default/src/features/redemption-codes/components/redemptions-mutate-drawer.tsx index 47f7a387c34c..af89212d2fce 100644 --- a/web/default/src/features/redemption-codes/components/redemptions-mutate-drawer.tsx +++ b/web/default/src/features/redemption-codes/components/redemptions-mutate-drawer.tsx @@ -30,6 +30,16 @@ import { sideDrawerFormClassName, sideDrawerHeaderClassName, } from '@/components/drawer-layout' +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from '@/components/ui/alert-dialog' import { Button } from '@/components/ui/button' import { Form, @@ -72,6 +82,27 @@ type RedemptionsMutateDrawerProps = { currentRow?: Redemption } +type RedemptionExportDialogState = { + open: boolean + keys: string[] + filename: string +} + +function sanitizeDownloadFilename(name: string) { + const sanitized = name.replace(/[/\\?%*:|"<>]/g, '_').trim() + return sanitized || 'redemption-codes' +} + +function downloadRedemptionCodes(keys: string[], filename: string) { + const blob = new Blob([keys.join('\n')], { type: 'text/plain;charset=utf-8' }) + const url = URL.createObjectURL(blob) + const anchor = document.createElement('a') + anchor.href = url + anchor.download = filename + anchor.click() + URL.revokeObjectURL(url) +} + export function RedemptionsMutateDrawer({ open, onOpenChange, @@ -81,6 +112,11 @@ export function RedemptionsMutateDrawer({ const isUpdate = !!currentRow const { triggerRefresh } = useRedemptions() const [isSubmitting, setIsSubmitting] = useState(false) + const [exportDialog, setExportDialog] = useState({ + open: false, + keys: [], + filename: 'redemption-codes.txt', + }) const form = useForm({ resolver: zodResolver(getRedemptionFormSchema(t)), @@ -131,6 +167,15 @@ export function RedemptionsMutateDrawer({ ) onOpenChange(false) triggerRefresh() + if (result.data && result.data.length > 0) { + const redemptionName = + basePayload.name?.trim() || formatQuota(basePayload.quota) + setExportDialog({ + open: true, + keys: result.data, + filename: `${sanitizeDownloadFilename(redemptionName)}.txt`, + }) + } } } } finally { @@ -164,7 +209,8 @@ export function RedemptionsMutateDrawer({ : t('Enter quota in {{currency}}', { currency: currencyLabel }) return ( - + { onOpenChange(v) @@ -339,5 +385,44 @@ export function RedemptionsMutateDrawer({ + + { + if (!open) { + setExportDialog((previous) => ({ ...previous, open: false })) + } + }} + > + + + + {t('Redemption code(s) created successfully')} + + + {t( + 'Do you want to download the created redemption codes as a text file?' + )} +
+ {t('The download will use the redemption name as the filename.')} +
+
+ + {t('Cancel')} + { + downloadRedemptionCodes( + exportDialog.keys, + exportDialog.filename + ) + setExportDialog((previous) => ({ ...previous, open: false })) + }} + > + {t('Download')} + + +
+
+ ) } diff --git a/web/default/src/features/redemption-codes/components/redemptions-table.tsx b/web/default/src/features/redemption-codes/components/redemptions-table.tsx index 391d64cff42a..13849620194f 100644 --- a/web/default/src/features/redemption-codes/components/redemptions-table.tsx +++ b/web/default/src/features/redemption-codes/components/redemptions-table.tsx @@ -170,7 +170,7 @@ export function RedemptionsTable() { skeletonKeyPrefix='redemptions-skeleton' applyHeaderSize toolbarProps={{ - searchPlaceholder: t('Filter by name or ID...'), + searchPlaceholder: t('Filter by name, ID, or redemption code...'), filters: [ { columnId: 'status', diff --git a/web/default/src/features/system-settings/auth/bot-protection-section.tsx b/web/default/src/features/system-settings/auth/bot-protection-section.tsx index 331613934142..e91c8254da8a 100644 --- a/web/default/src/features/system-settings/auth/bot-protection-section.tsx +++ b/web/default/src/features/system-settings/auth/bot-protection-section.tsx @@ -20,6 +20,7 @@ import { zodResolver } from '@hookform/resolvers/zod' import { useEffect } from 'react' import { useForm } from 'react-hook-form' import { useTranslation } from 'react-i18next' +import { toast } from 'sonner' import * as z from 'zod' import { @@ -55,6 +56,8 @@ type BotProtectionSectionProps = { defaultValues: BotProtectionFormValues } +const SECRET_MASK = '********' + export function BotProtectionSection({ defaultValues, }: BotProtectionSectionProps) { @@ -71,13 +74,58 @@ export function BotProtectionSection({ }, [defaultValues, form]) const onSubmit = async (data: BotProtectionFormValues) => { - const updates = Object.entries(data).filter( - ([key, value]) => - value !== defaultValues[key as keyof BotProtectionFormValues] - ) + const siteKey = (data.TurnstileSiteKey || '').trim() + const secretKey = (data.TurnstileSecretKey || '').trim() + + if (data.TurnstileCheckEnabled && !siteKey) { + toast.error( + t( + 'Unable to enable Turnstile. Please fill in the Turnstile site key first.' + ) + ) + return + } + + // Save keys before enabling — backend rejects enable when site key is empty + const updates: Array<{ key: string; value: string }> = [] + + if (siteKey !== (defaultValues.TurnstileSiteKey || '')) { + updates.push({ key: 'TurnstileSiteKey', value: siteKey }) + } + + // Secret is masked when already configured; only send a real new secret + if ( + secretKey && + secretKey !== SECRET_MASK && + secretKey !== (defaultValues.TurnstileSecretKey || '') + ) { + updates.push({ key: 'TurnstileSecretKey', value: secretKey }) + } + + if (data.TurnstileCheckEnabled !== defaultValues.TurnstileCheckEnabled) { + updates.push({ + key: 'TurnstileCheckEnabled', + value: String(data.TurnstileCheckEnabled), + }) + } + + if (updates.length === 0) { + toast.message(t('No changes to save')) + return + } - for (const [key, value] of updates) { - await updateOption.mutateAsync({ key, value: value ?? '' }) + try { + for (const item of updates) { + await updateOption.mutateAsync(item) + } + form.reset({ + ...data, + TurnstileSiteKey: siteKey, + TurnstileSecretKey: + secretKey && secretKey !== SECRET_MASK ? SECRET_MASK : secretKey, + }) + } catch { + // toast handled by mutation } } @@ -98,7 +146,7 @@ export function BotProtectionSection({ {t('Enable Turnstile')} {t( - 'Protect login and registration with Cloudflare Turnstile' + 'Protect login, registration and lottery draws with Cloudflare Turnstile' )} @@ -125,6 +173,11 @@ export function BotProtectionSection({ {...field} /> + + {t( + 'Public site key from Cloudflare Turnstile. Required for the widget to render.' + )} + )} @@ -144,6 +197,11 @@ export function BotProtectionSection({ {...field} /> + + {t( + 'If already saved, this field shows ********. Leave it unchanged unless you need to replace the secret.' + )} + )} diff --git a/web/default/src/features/system-settings/extensions/availability-monitor-section.tsx b/web/default/src/features/system-settings/extensions/availability-monitor-section.tsx new file mode 100644 index 000000000000..346fd5010526 --- /dev/null +++ b/web/default/src/features/system-settings/extensions/availability-monitor-section.tsx @@ -0,0 +1,251 @@ +/* +Copyright (C) 2023-2026 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 . + +For commercial licensing, please contact support@quantumnous.com +*/ +import { zodResolver } from '@hookform/resolvers/zod' +import { useEffect } from 'react' +import { useForm } from 'react-hook-form' +import { useTranslation } from 'react-i18next' +import { toast } from 'sonner' +import * as z from 'zod' + +import { + Form, + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, + FormMessage, +} from '@/components/ui/form' +import { Input } from '@/components/ui/input' +import { + Select, + SelectContent, + SelectGroup, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select' +import { Switch } from '@/components/ui/switch' + +import { + SettingsForm, + SettingsSwitchContent, + SettingsSwitchItem, +} from '../components/settings-form-layout' +import { SettingsPageFormActions } from '../components/settings-page-context' +import { SettingsSection } from '../components/settings-section' +import { useUpdateOption } from '../hooks/use-update-option' +import { safeNumberFieldProps } from '../utils/numeric-field' +import { + DEFAULT_EXTENSION_VISIBILITY, + EXTENSION_VISIBILITY_OPTIONS, + resolveExtensionVisibility, +} from './constants' + +/** + * react-hook-form treats dotted names as nested paths. Keep the form nested + * and flatten to option keys only when persisting (same pattern as + * performance-section). + */ +const availabilityMonitorSchema = z.object({ + console_setting: z.object({ + availability_monitor_enabled: z.boolean(), + availability_monitor_visibility: z.enum(['all', 'admin']), + availability_monitor_refresh_interval: z.number().int().min(5).max(3600), + }), +}) + +type AvailabilityMonitorFormValues = z.infer + +type FlatAvailabilityDefaults = { + 'console_setting.availability_monitor_enabled': boolean + 'console_setting.availability_monitor_visibility': 'all' | 'admin' | string + 'console_setting.availability_monitor_refresh_interval': number +} + +type AvailabilityMonitorSectionProps = { + defaultValues: FlatAvailabilityDefaults +} + +function buildFormDefaults( + defaults: FlatAvailabilityDefaults +): AvailabilityMonitorFormValues { + return { + console_setting: { + availability_monitor_enabled: + defaults['console_setting.availability_monitor_enabled'], + availability_monitor_visibility: resolveExtensionVisibility( + defaults['console_setting.availability_monitor_visibility'] || + DEFAULT_EXTENSION_VISIBILITY + ), + availability_monitor_refresh_interval: + defaults['console_setting.availability_monitor_refresh_interval'], + }, + } +} + +function flattenFormValues( + values: AvailabilityMonitorFormValues +): FlatAvailabilityDefaults { + return { + 'console_setting.availability_monitor_enabled': + values.console_setting.availability_monitor_enabled, + 'console_setting.availability_monitor_visibility': + values.console_setting.availability_monitor_visibility, + 'console_setting.availability_monitor_refresh_interval': + values.console_setting.availability_monitor_refresh_interval, + } +} + +export function AvailabilityMonitorSection( + props: AvailabilityMonitorSectionProps +) { + const { t } = useTranslation() + const updateOption = useUpdateOption() + const form = useForm({ + resolver: zodResolver(availabilityMonitorSchema), + defaultValues: buildFormDefaults(props.defaultValues), + }) + + useEffect(() => { + form.reset(buildFormDefaults(props.defaultValues)) + }, [props.defaultValues, form]) + + const onSubmit = async (values: AvailabilityMonitorFormValues) => { + const flatValues = flattenFormValues(values) + const updates = Object.entries(flatValues).filter( + ([key, value]) => + value !== + props.defaultValues[key as keyof FlatAvailabilityDefaults] + ) + + if (updates.length === 0) { + toast.info(t('No changes to save')) + return + } + + for (const [key, value] of updates) { + await updateOption.mutateAsync({ key, value }) + } + } + + const isEnabled = form.watch( + 'console_setting.availability_monitor_enabled' + ) + + return ( + +
+ + ( + + + {t('Enable availability monitor')} + + {t( + 'Shows a group-level request heartbeat chart under Extensions. Failed requests require ERROR_LOG_ENABLED.' + )} + + + + + + + )} + /> + ( + + {t('Visibility')} + + + {t( + 'Choose who can see the Availability Monitor entry in the Extensions sidebar.' + )} + + + + )} + /> + ( + + {t('Refresh interval (seconds)')} + + + + + {t( + 'How often the Availability Monitor page automatically reloads data. Allowed range: 5–3600 seconds.' + )} + + + + )} + /> + + +
+
+ ) +} diff --git a/web/default/src/features/system-settings/extensions/constants.ts b/web/default/src/features/system-settings/extensions/constants.ts new file mode 100644 index 000000000000..380593ba45ee --- /dev/null +++ b/web/default/src/features/system-settings/extensions/constants.ts @@ -0,0 +1,120 @@ +/* +Copyright (C) 2023-2026 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, + 70|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 . + +For commercial licensing, please contact support@quantumnous.com +*/ +import { + Bookmark, + BookOpen, + ExternalLink, + FileText, + FolderOpen, + Globe, + HelpCircle, + Layout, + Link, + Newspaper, + type LucideIcon, +} from 'lucide-react' + +export const CUSTOM_PAGE_ICON_OPTIONS = [ + { value: 'Link', icon: Link }, + { value: 'BookOpen', icon: BookOpen }, + { value: 'ExternalLink', icon: ExternalLink }, + { value: 'FileText', icon: FileText }, + { value: 'Globe', icon: Globe }, + { value: 'Layout', icon: Layout }, + { value: 'Newspaper', icon: Newspaper }, + { value: 'HelpCircle', icon: HelpCircle }, + { value: 'Bookmark', icon: Bookmark }, + { value: 'FolderOpen', icon: FolderOpen }, +] as const + +export type CustomPageIconName = + (typeof CUSTOM_PAGE_ICON_OPTIONS)[number]['value'] + +export const DEFAULT_CUSTOM_PAGE_ICON: CustomPageIconName = 'Link' + +export const CUSTOM_PAGE_OPEN_MODES = [ + { value: 'embed', labelKey: 'Embed in console' }, + { value: 'external', labelKey: 'Open in new tab' }, +] as const + +export type CustomPageOpenMode = + (typeof CUSTOM_PAGE_OPEN_MODES)[number]['value'] + +export const DEFAULT_CUSTOM_PAGE_OPEN_MODE: CustomPageOpenMode = 'embed' + +export const EXTENSION_VISIBILITY_OPTIONS = [ + { value: 'all', labelKey: 'Everyone' }, + { value: 'admin', labelKey: 'Admins only' }, +] as const + +export type ExtensionVisibility = + (typeof EXTENSION_VISIBILITY_OPTIONS)[number]['value'] + +export const DEFAULT_EXTENSION_VISIBILITY: ExtensionVisibility = 'all' + +const ICON_MAP: Record = Object.fromEntries( + CUSTOM_PAGE_ICON_OPTIONS.map((item) => [item.value, item.icon]) +) + +export function resolveCustomPageIcon( + iconName: string | undefined | null +): LucideIcon { + if (!iconName) return Link + return ICON_MAP[iconName] ?? Link +} + +export function resolveCustomPageOpenMode( + openMode: string | undefined | null +): CustomPageOpenMode { + if (openMode === 'external') return 'external' + return 'embed' +} + +export function resolveExtensionVisibility( + visibility: string | undefined | null +): ExtensionVisibility { + if (visibility === 'admin') return 'admin' + return 'all' +} + +export type CustomPage = { + id: string + title: string + icon: string + url: string + open_mode: CustomPageOpenMode + visibility: ExtensionVisibility + enabled: boolean + sort: number +} + +export type CustomPageStatusItem = { + id: string + title: string + icon: string + url: string + open_mode?: CustomPageOpenMode +} + +export function createCustomPageId(): string { + if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') { + return `cp_${crypto.randomUUID().replaceAll('-', '').slice(0, 16)}` + } + return `cp_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}` +} diff --git a/web/default/src/features/system-settings/extensions/custom-pages-section.tsx b/web/default/src/features/system-settings/extensions/custom-pages-section.tsx new file mode 100644 index 000000000000..fa958ec9e547 --- /dev/null +++ b/web/default/src/features/system-settings/extensions/custom-pages-section.tsx @@ -0,0 +1,685 @@ +/* +Copyright (C) 2023-2026 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, + 70|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 . + +For commercial licensing, please contact support@quantumnous.com +*/ +import { zodResolver } from '@hookform/resolvers/zod' +import { Plus, Save, Trash2 } from 'lucide-react' +import { useEffect, useMemo, useState } from 'react' +import { useForm } from 'react-hook-form' +import { useTranslation } from 'react-i18next' +import { toast } from 'sonner' +import * as z from 'zod' + +import { StaticDataTable } from '@/components/data-table/static/static-data-table' +import { StaticRowActions } from '@/components/data-table/static/static-row-actions' +import { Dialog } from '@/components/dialog' +import { StatusBadge } from '@/components/status-badge' +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from '@/components/ui/alert-dialog' +import { Button } from '@/components/ui/button' +import { Checkbox } from '@/components/ui/checkbox' +import { + Form, + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, + FormMessage, +} from '@/components/ui/form' +import { Input } from '@/components/ui/input' +import { + Select, + SelectContent, + SelectGroup, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select' +import { Switch } from '@/components/ui/switch' + +import { SettingsSection } from '../components/settings-section' +import { useUpdateOption } from '../hooks/use-update-option' +import { + CUSTOM_PAGE_ICON_OPTIONS, + CUSTOM_PAGE_OPEN_MODES, + DEFAULT_CUSTOM_PAGE_ICON, + DEFAULT_CUSTOM_PAGE_OPEN_MODE, + DEFAULT_EXTENSION_VISIBILITY, + EXTENSION_VISIBILITY_OPTIONS, + createCustomPageId, + resolveCustomPageIcon, + resolveCustomPageOpenMode, + resolveExtensionVisibility, + type CustomPage, +} from './constants' + +type CustomPagesSectionProps = { + data: string +} + +const customPageSchema = z.object({ + title: z + .string() + .min(1, 'Title is required') + .max(100, 'Title must be less than 100 characters'), + icon: z.string().min(1, 'Icon is required'), + url: z + .string() + .trim() + .refine( + (value) => value === '' || /^https?:\/\//i.test(value), + 'URL must start with http:// or https://' + ) + .max(500, 'URL must be less than 500 characters'), + open_mode: z.enum(['embed', 'external']), + visibility: z.enum(['all', 'admin']), + enabled: z.boolean(), + sort: z.number().int(), +}) + +type CustomPageFormValues = z.infer + +const CUSTOM_PAGE_FORM_ID = 'custom-page-form' + +function parseCustomPages(data: string): CustomPage[] { + try { + const parsed = JSON.parse(data || '[]') + if (!Array.isArray(parsed)) return [] + return parsed.map((item, idx) => ({ + id: + typeof item?.id === 'string' && item.id.trim() + ? item.id.trim() + : createCustomPageId(), + title: typeof item?.title === 'string' ? item.title : '', + icon: + typeof item?.icon === 'string' && item.icon.trim() + ? item.icon + : DEFAULT_CUSTOM_PAGE_ICON, + url: typeof item?.url === 'string' ? item.url : '', + open_mode: resolveCustomPageOpenMode(item?.open_mode), + visibility: resolveExtensionVisibility(item?.visibility), + enabled: Boolean(item?.enabled), + sort: Number.isFinite(Number(item?.sort)) ? Number(item.sort) : idx, + })) + } catch { + return [] + } +} + +export function CustomPagesSection(props: CustomPagesSectionProps) { + const { t } = useTranslation() + const updateOption = useUpdateOption() + const [pages, setPages] = useState([]) + const [hasChanges, setHasChanges] = useState(false) + const [selectedIds, setSelectedIds] = useState([]) + const [showDialog, setShowDialog] = useState(false) + const [showDeleteDialog, setShowDeleteDialog] = useState(false) + const [editingPage, setEditingPage] = useState(null) + const [deleteTarget, setDeleteTarget] = useState<'single' | 'batch'>('single') + + const form = useForm({ + resolver: zodResolver(customPageSchema), + defaultValues: { + title: '', + icon: DEFAULT_CUSTOM_PAGE_ICON, + url: '', + open_mode: DEFAULT_CUSTOM_PAGE_OPEN_MODE, + visibility: DEFAULT_EXTENSION_VISIBILITY, + enabled: true, + sort: 0, + }, + }) + + useEffect(() => { + setPages(parseCustomPages(props.data)) + setHasChanges(false) + setSelectedIds([]) + }, [props.data]) + + const sortedPages = useMemo( + () => + [...pages].sort((a, b) => { + if (a.sort !== b.sort) return a.sort - b.sort + return a.id.localeCompare(b.id) + }), + [pages] + ) + + const handleAdd = () => { + setEditingPage(null) + const nextSort = + pages.reduce((max, page) => Math.max(max, page.sort), -1) + 1 + form.reset({ + title: '', + icon: DEFAULT_CUSTOM_PAGE_ICON, + url: '', + open_mode: DEFAULT_CUSTOM_PAGE_OPEN_MODE, + visibility: DEFAULT_EXTENSION_VISIBILITY, + enabled: true, + sort: nextSort, + }) + setShowDialog(true) + } + + const handleEdit = (page: CustomPage) => { + setEditingPage(page) + form.reset({ + title: page.title, + icon: page.icon || DEFAULT_CUSTOM_PAGE_ICON, + url: page.url, + open_mode: page.open_mode || DEFAULT_CUSTOM_PAGE_OPEN_MODE, + visibility: page.visibility || DEFAULT_EXTENSION_VISIBILITY, + enabled: page.enabled, + sort: page.sort, + }) + setShowDialog(true) + } + + const handleDelete = (page: CustomPage) => { + setEditingPage(page) + setDeleteTarget('single') + setShowDeleteDialog(true) + } + + const handleBatchDelete = () => { + if (selectedIds.length === 0) { + toast.error(t('Please select items to delete')) + return + } + setDeleteTarget('batch') + setShowDeleteDialog(true) + } + + const confirmDelete = () => { + if (deleteTarget === 'single' && editingPage) { + setPages((prev) => prev.filter((item) => item.id !== editingPage.id)) + setHasChanges(true) + toast.success( + t('Custom page deleted. Click "Save Settings" to apply.') + ) + } else if (deleteTarget === 'batch') { + setPages((prev) => + prev.filter((item) => !selectedIds.includes(item.id)) + ) + setSelectedIds([]) + setHasChanges(true) + toast.success( + t( + '{{count}} custom pages deleted. Click "Save Settings" to apply.', + { count: selectedIds.length } + ) + ) + } + setShowDeleteDialog(false) + setEditingPage(null) + } + + const handleSubmitForm = (values: CustomPageFormValues) => { + if (editingPage) { + setPages((prev) => + prev.map((item) => + item.id === editingPage.id ? { ...item, ...values } : item + ) + ) + toast.success( + t('Custom page updated. Click "Save Settings" to apply.') + ) + } else { + setPages((prev) => [ + ...prev, + { + id: createCustomPageId(), + ...values, + }, + ]) + toast.success(t('Custom page added. Click "Save Settings" to apply.')) + } + setHasChanges(true) + setShowDialog(false) + } + + const handleSaveAll = async () => { + try { + await updateOption.mutateAsync({ + key: 'console_setting.custom_pages', + value: JSON.stringify(pages), + }) + setHasChanges(false) + toast.success(t('Custom pages saved successfully')) + } catch { + toast.error(t('Failed to save custom pages')) + } + } + + const toggleSelectAll = (checked: boolean) => { + setSelectedIds(checked ? sortedPages.map((item) => item.id) : []) + } + + const toggleSelectOne = (id: string, checked: boolean) => { + setSelectedIds((prev) => + checked ? [...prev, id] : prev.filter((item) => item !== id) + ) + } + + return ( + +
+
+
+ + + +
+
+ +

+ {t( + 'Enabled pages with a URL appear under the Extensions group in the console sidebar and open as embedded pages.' + )} +

+ + page.id} + emptyContent={t( + 'No custom pages yet. Click "Add Custom Page" to create one.' + )} + columns={[ + { + id: 'select', + header: ( + 0 + } + onCheckedChange={toggleSelectAll} + /> + ), + className: 'w-12', + cell: (page) => ( + + toggleSelectOne(page.id, checked as boolean) + } + /> + ), + }, + { + id: 'title', + header: t('Title'), + cellClassName: 'max-w-xs truncate font-medium', + cell: (page) => { + const Icon = resolveCustomPageIcon(page.icon) + return ( + + + {page.title} + + ) + }, + }, + { + id: 'url', + header: t('URL'), + cellClassName: 'text-muted-foreground max-w-md truncate', + cell: (page) => page.url || '—', + }, + { + id: 'open_mode', + header: t('Open mode'), + className: 'w-36', + cell: (page) => + page.open_mode === 'external' + ? t('Open in new tab') + : t('Embed in console'), + }, + { + id: 'visibility', + header: t('Visibility'), + className: 'w-32', + cell: (page) => + page.visibility === 'admin' + ? t('Admins only') + : t('Everyone'), + }, + { + id: 'sort', + header: t('Sort'), + className: 'w-20', + cell: (page) => page.sort, + }, + { + id: 'enabled', + header: t('Status'), + className: 'w-28', + cell: (page) => ( + + ), + }, + { + id: 'actions', + header: t('Actions'), + cell: (page) => ( + handleEdit(page)} + onDelete={() => handleDelete(page)} + /> + ), + }, + ]} + /> +
+ + + + + + } + > +
+ + ( + + {t('Title')} + + + + + {t('Shown in the console sidebar. Maximum 100 characters.')} + + + + )} + /> + ( + + {t('Icon')} + + + + )} + /> + ( + + {t('URL')} + + + + + {t( + 'Must be http(s). Leave empty to keep the page hidden from the sidebar.' + )} + + + + )} + /> + ( + + {t('Open mode')} + + + {t( + 'Use “Open in new tab” for sites that block iframe embedding (for example Taobao / Xianyu short links).' + )} + + + + )} + /> + ( + + {t('Visibility')} + + + {t( + 'Choose who can see this page in the Extensions sidebar.' + )} + + + + )} + /> + ( + + {t('Sort')} + + { + const next = event.target.valueAsNumber + field.onChange(Number.isFinite(next) ? next : 0) + }} + /> + + + {t('Lower numbers appear first in the sidebar.')} + + + + )} + /> + ( + +
+ {t('Enabled')} + + {t( + 'Only enabled pages with a URL are shown in the Extensions sidebar group.' + )} + +
+ + + +
+ )} + /> + + +
+ + + + + {t('Are you sure?')} + + {deleteTarget === 'single' + ? t('This custom page will be removed from the list.') + : t( + '{{count}} custom pages will be removed from the list.', + { count: selectedIds.length } + )} + + + + {t('Cancel')} + + {t('Delete')} + + + + +
+ ) +} diff --git a/web/default/src/features/system-settings/extensions/index.tsx b/web/default/src/features/system-settings/extensions/index.tsx new file mode 100644 index 000000000000..c47f6020fa0d --- /dev/null +++ b/web/default/src/features/system-settings/extensions/index.tsx @@ -0,0 +1,54 @@ +/* +Copyright (C) 2023-2026 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, + 70|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 . + +For commercial licensing, please contact support@quantumnous.com +*/ +import { SettingsPage } from '../components/settings-page' +import type { ExtensionsSettings } from '../types' +import { + EXTENSIONS_DEFAULT_SECTION, + getExtensionsSectionContent, + getExtensionsSectionMeta, +} from './section-registry' + +const defaultExtensionsSettings: ExtensionsSettings = { + 'console_setting.custom_pages': '[]', + 'console_setting.availability_monitor_enabled': true, + 'console_setting.availability_monitor_visibility': 'all', + 'console_setting.availability_monitor_refresh_interval': 10, + 'lottery_setting.enabled': false, + 'lottery_setting.daily_pool_usd': 100, + 'lottery_setting.display_daily_pool_usd': 8888, + 'lottery_setting.min_bet_usd': 0.01, + 'lottery_setting.max_bet_usd': 10, + 'lottery_setting.max_draws_per_ip_per_day': 3, + 'lottery_setting.require_redemption': true, + 'lottery_setting.free_prizes': '[]', + 'lottery_setting.bet_prizes': '[]', +} + +export function ExtensionsSettingsPage() { + return ( + + ) +} diff --git a/web/default/src/features/system-settings/extensions/lottery-settings-section.tsx b/web/default/src/features/system-settings/extensions/lottery-settings-section.tsx new file mode 100644 index 000000000000..ba6319d9bb7e --- /dev/null +++ b/web/default/src/features/system-settings/extensions/lottery-settings-section.tsx @@ -0,0 +1,618 @@ +/* +Copyright (C) 2023-2026 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 . + +For commercial licensing, please contact support@quantumnous.com +*/ +import { zodResolver } from '@hookform/resolvers/zod' +import { Plus, Trash2 } from 'lucide-react' +import { useEffect } from 'react' +import { useFieldArray, useForm, type Resolver } from 'react-hook-form' +import { useTranslation } from 'react-i18next' +import { toast } from 'sonner' +import { z } from 'zod' + +import { Button } from '@/components/ui/button' +import { + Form, + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, + FormMessage, +} from '@/components/ui/form' +import { Input } from '@/components/ui/input' +import { Switch } from '@/components/ui/switch' + +import { + SettingsForm, + SettingsSwitchContent, + SettingsSwitchItem, +} from '../components/settings-form-layout' +import { SettingsPageFormActions } from '../components/settings-page-context' +import { SettingsSection } from '../components/settings-section' +import { useUpdateOption } from '../hooks/use-update-option' + +const freePrizeSchema = z.object({ + name: z.string().min(1), + usd: z.coerce.number().min(0), + weight: z.coerce.number().int().min(1), + is_thanks: z.boolean(), +}) + +const betPrizeSchema = z.object({ + name: z.string().min(1), + multiplier: z.coerce.number().min(-1).max(2), + weight: z.coerce.number().int().min(1), + is_thanks: z.boolean(), +}) + +const schema = z.object({ + enabled: z.boolean(), + dailyPoolUsd: z.coerce.number().min(0), + displayDailyPoolUsd: z.coerce.number().min(0), + minBetUsd: z.coerce.number().min(0), + maxBetUsd: z.coerce.number().min(0), + maxDrawsPerIpPerDay: z.coerce.number().int().min(0), + requireRedemption: z.boolean(), + freePrizes: z.array(freePrizeSchema).min(1), + betPrizes: z.array(betPrizeSchema).min(1), +}) + +type Values = z.infer + +type FreePrize = z.infer +type BetPrize = z.infer + +const DEFAULT_FREE: FreePrize[] = [ + { name: '谢谢惠顾', usd: 0, weight: 28, is_thanks: true }, + { name: '安慰奖', usd: 0.01, weight: 18, is_thanks: false }, + { name: '小奖', usd: 0.05, weight: 15, is_thanks: false }, + { name: '普通奖', usd: 0.2, weight: 12, is_thanks: false }, + { name: '中奖', usd: 0.5, weight: 10, is_thanks: false }, + { name: '大奖', usd: 1, weight: 7, is_thanks: false }, + { name: '超级大奖', usd: 2, weight: 5, is_thanks: false }, + { name: '传说奖', usd: 5, weight: 3, is_thanks: false }, + { name: '头奖', usd: 20, weight: 2, is_thanks: false }, +] + +const DEFAULT_BET: BetPrize[] = [ + { name: '血本无归', multiplier: -1, weight: 12, is_thanks: false }, + { name: '大亏', multiplier: -0.5, weight: 12, is_thanks: false }, + { name: '小亏', multiplier: -0.2, weight: 14, is_thanks: false }, + { name: '谢谢惠顾', multiplier: 0, weight: 18, is_thanks: true }, + { name: '回本碎银', multiplier: 0.2, weight: 14, is_thanks: false }, + { name: '小赚', multiplier: 0.5, weight: 12, is_thanks: false }, + { name: '翻倍', multiplier: 1, weight: 8, is_thanks: false }, + { name: '大赚', multiplier: 1.5, weight: 6, is_thanks: false }, + { name: '暴击', multiplier: 2, weight: 4, is_thanks: false }, +] + +function parseFreePrizes(raw: string | undefined): FreePrize[] { + if (!raw || raw === '[]') return DEFAULT_FREE + try { + const list = JSON.parse(raw) as Array> + if (!Array.isArray(list) || list.length === 0) return DEFAULT_FREE + return list.map((item) => { + let usd = 0 + if (typeof item.usd === 'number') { + usd = item.usd + } else if (typeof item.quota === 'number') { + usd = Number(item.quota) / 500000 + } + return { + name: String(item.name || ''), + usd, + weight: Number(item.weight) || 1, + is_thanks: Boolean(item.is_thanks), + } + }) + } catch { + return DEFAULT_FREE + } +} + +function parseBetPrizes(raw: string | undefined): BetPrize[] { + if (!raw || raw === '[]') return DEFAULT_BET + try { + const list = JSON.parse(raw) as Array> + if (!Array.isArray(list) || list.length === 0) return DEFAULT_BET + return list.map((item) => ({ + name: String(item.name || ''), + multiplier: Number(item.multiplier) || 0, + weight: Number(item.weight) || 1, + is_thanks: Boolean(item.is_thanks), + })) + } catch { + return DEFAULT_BET + } +} + +export function LotterySettingsSection({ + defaultValues, +}: { + defaultValues: { + enabled: boolean + dailyPoolUsd: number + displayDailyPoolUsd: number + minBetUsd: number + maxBetUsd: number + maxDrawsPerIpPerDay: number + requireRedemption: boolean + freePrizesJson: string + betPrizesJson: string + } +}) { + const { t } = useTranslation() + const updateOption = useUpdateOption() + + const form = useForm({ + resolver: zodResolver(schema) as unknown as Resolver, + defaultValues: { + enabled: defaultValues.enabled, + dailyPoolUsd: defaultValues.dailyPoolUsd, + displayDailyPoolUsd: defaultValues.displayDailyPoolUsd, + minBetUsd: defaultValues.minBetUsd, + maxBetUsd: defaultValues.maxBetUsd, + maxDrawsPerIpPerDay: defaultValues.maxDrawsPerIpPerDay, + requireRedemption: defaultValues.requireRedemption, + freePrizes: parseFreePrizes(defaultValues.freePrizesJson), + betPrizes: parseBetPrizes(defaultValues.betPrizesJson), + }, + }) + + useEffect(() => { + form.reset({ + enabled: defaultValues.enabled, + dailyPoolUsd: defaultValues.dailyPoolUsd, + displayDailyPoolUsd: defaultValues.displayDailyPoolUsd, + minBetUsd: defaultValues.minBetUsd, + maxBetUsd: defaultValues.maxBetUsd, + maxDrawsPerIpPerDay: defaultValues.maxDrawsPerIpPerDay, + requireRedemption: defaultValues.requireRedemption, + freePrizes: parseFreePrizes(defaultValues.freePrizesJson), + betPrizes: parseBetPrizes(defaultValues.betPrizesJson), + }) + }, [defaultValues, form]) + + const freeArray = useFieldArray({ control: form.control, name: 'freePrizes' }) + const betArray = useFieldArray({ control: form.control, name: 'betPrizes' }) + + const { isDirty, isSubmitting } = form.formState + + async function onSubmit(values: Values) { + if (values.minBetUsd > values.maxBetUsd) { + toast.error(t('Min bet cannot exceed max bet')) + return + } + + const freePayload = values.freePrizes.map((p) => ({ + name: p.name, + usd: p.usd, + weight: p.weight, + is_thanks: p.is_thanks, + })) + const betPayload = values.betPrizes.map((p) => ({ + name: p.name, + multiplier: p.multiplier, + weight: p.weight, + is_thanks: p.is_thanks, + })) + + const updates: Array<{ key: string; value: string }> = [ + { key: 'lottery_setting.enabled', value: String(values.enabled) }, + { + key: 'lottery_setting.daily_pool_usd', + value: String(values.dailyPoolUsd), + }, + { + key: 'lottery_setting.display_daily_pool_usd', + value: String(values.displayDailyPoolUsd), + }, + { key: 'lottery_setting.min_bet_usd', value: String(values.minBetUsd) }, + { key: 'lottery_setting.max_bet_usd', value: String(values.maxBetUsd) }, + { + key: 'lottery_setting.max_draws_per_ip_per_day', + value: String(values.maxDrawsPerIpPerDay), + }, + { + key: 'lottery_setting.require_redemption', + value: String(values.requireRedemption), + }, + { + key: 'lottery_setting.free_prizes', + value: JSON.stringify(freePayload), + }, + { + key: 'lottery_setting.bet_prizes', + value: JSON.stringify(betPayload), + }, + ] + + try { + for (const item of updates) { + await updateOption.mutateAsync(item) + } + toast.success(t('Settings saved')) + form.reset(values) + } catch { + toast.error(t('Failed to save settings')) + } + } + + return ( + +
+ + + + ( + + + {t('Enable lucky slot')} + + {t( + 'Shows Lucky Slot under Extensions. Draws are decided by the backend once per user per day.' + )} + + + + + + + )} + /> + +
+ ( + + + {t('Display daily prize pool (USD)')} + + + + + + {t( + 'Shown to users on the lottery page. Does not limit real payouts. Doubled on Thursdays. Set 0 to fall back to the actual pool.' + )} + + + + )} + /> + + ( + + {t('Actual daily pool limit (USD)')} + + + + + {t( + 'Real daily payout cap used by the backend. Hidden from users. Doubled on Thursdays.' + )} + + + + )} + /> + + ( + + + + {t('Require redemption code to play')} + + + {t( + 'On normal days, users must have redeemed at least one code. Crazy Thursday skips this requirement.' + )} + + + + + + + )} + /> + +
+ ( + + {t('Min bet (USD)')} + + + + + + )} + /> + ( + + {t('Max bet (USD)')} + + + + + + )} + /> + ( + + {t('Max draws per IP / day')} + + + + + {t('0 means no IP limit.')} + + + + )} + /> +
+ +
+
+
+

+ {t('Free mode prizes')} +

+

+ {t( + 'Each row is a prize. Higher USD should usually have lower weight.' + )} +

+
+ +
+ +
+ {freeArray.fields.map((field, index) => ( +
+ ( + + {t('Name')} + + + + + + )} + /> + ( + + {t('USD')} + + + + + + )} + /> + ( + + {t('Weight')} + + + + + + )} + /> + ( + + {t('Thanks')} + + + + + )} + /> + +
+ ))} +
+
+ +
+
+
+

+ {t('Bet mode prizes')} +

+

+ {t( + 'Multiplier is relative to bet amount. Range: -1 to 2.' + )} +

+
+ +
+ +
+ {betArray.fields.map((field, index) => ( +
+ ( + + {t('Name')} + + + + + + )} + /> + ( + + {t('Multiplier')} + + + + + + )} + /> + ( + + {t('Weight')} + + + + + + )} + /> + ( + + {t('Thanks')} + + + + + )} + /> + +
+ ))} +
+
+
+
+
+
+ ) +} diff --git a/web/default/src/features/system-settings/extensions/section-registry.ts b/web/default/src/features/system-settings/extensions/section-registry.ts new file mode 100644 index 000000000000..bf32e80f1fc8 --- /dev/null +++ b/web/default/src/features/system-settings/extensions/section-registry.ts @@ -0,0 +1,102 @@ +/* +Copyright (C) 2023-2026 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, + 70|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 . + +For commercial licensing, please contact support@quantumnous.com +*/ +import { createElement } from 'react' + +import type { ExtensionsSettings } from '../types' +import { createSectionRegistry } from '../utils/section-registry' +import { AvailabilityMonitorSection } from './availability-monitor-section' +import { CustomPagesSection } from './custom-pages-section' +import { LotterySettingsSection } from './lottery-settings-section' +import { + DEFAULT_EXTENSION_VISIBILITY, + resolveExtensionVisibility, +} from './constants' + +const EXTENSIONS_SECTIONS = [ + { + id: 'pages', + titleKey: 'Custom Pages', + build: (settings: ExtensionsSettings) => + createElement(CustomPagesSection, { + data: settings['console_setting.custom_pages'], + }), + }, + { + id: 'availability', + titleKey: 'Availability Monitor', + build: (settings: ExtensionsSettings) => + createElement(AvailabilityMonitorSection, { + defaultValues: { + 'console_setting.availability_monitor_enabled': + settings['console_setting.availability_monitor_enabled'], + 'console_setting.availability_monitor_visibility': + resolveExtensionVisibility( + settings['console_setting.availability_monitor_visibility'] || + DEFAULT_EXTENSION_VISIBILITY + ), + 'console_setting.availability_monitor_refresh_interval': + settings['console_setting.availability_monitor_refresh_interval'], + }, + }), + }, + { + id: 'lottery', + titleKey: 'Lucky Slot Lottery', + build: (settings: ExtensionsSettings) => + createElement(LotterySettingsSection, { + defaultValues: { + enabled: settings['lottery_setting.enabled'] ?? false, + dailyPoolUsd: settings['lottery_setting.daily_pool_usd'] ?? 100, + displayDailyPoolUsd: + settings['lottery_setting.display_daily_pool_usd'] ?? 8888, + minBetUsd: settings['lottery_setting.min_bet_usd'] ?? 0.01, + maxBetUsd: settings['lottery_setting.max_bet_usd'] ?? 10, + maxDrawsPerIpPerDay: + settings['lottery_setting.max_draws_per_ip_per_day'] ?? 3, + requireRedemption: + settings['lottery_setting.require_redemption'] ?? true, + freePrizesJson: + settings['lottery_setting.free_prizes'] || + '[{"name":"谢谢惠顾","usd":0,"weight":28,"is_thanks":true}]', + betPrizesJson: + settings['lottery_setting.bet_prizes'] || + '[{"name":"谢谢惠顾","multiplier":0,"weight":18,"is_thanks":true}]', + }, + }), + }, +] as const + +export type ExtensionsSectionId = (typeof EXTENSIONS_SECTIONS)[number]['id'] + +const extensionsRegistry = createSectionRegistry< + ExtensionsSectionId, + ExtensionsSettings +>({ + sections: EXTENSIONS_SECTIONS, + defaultSection: 'pages', + basePath: '/system-settings/extensions', + urlStyle: 'path', +}) + +export const EXTENSIONS_SECTION_IDS = extensionsRegistry.sectionIds +export const EXTENSIONS_DEFAULT_SECTION = extensionsRegistry.defaultSection +export const getExtensionsSectionNavItems = + extensionsRegistry.getSectionNavItems +export const getExtensionsSectionContent = extensionsRegistry.getSectionContent +export const getExtensionsSectionMeta = extensionsRegistry.getSectionMeta diff --git a/web/default/src/features/system-settings/hooks/use-update-option.ts b/web/default/src/features/system-settings/hooks/use-update-option.ts index 670ccf9c44c5..4bfd9e098c02 100644 --- a/web/default/src/features/system-settings/hooks/use-update-option.ts +++ b/web/default/src/features/system-settings/hooks/use-update-option.ts @@ -37,6 +37,14 @@ const STATUS_RELATED_KEYS = [ 'general_setting.quota_display_type', 'general_setting.custom_currency_symbol', 'general_setting.custom_currency_exchange_rate', + 'console_setting.custom_pages', + 'console_setting.availability_monitor_enabled', + 'console_setting.availability_monitor_visibility', + 'console_setting.availability_monitor_refresh_interval', + // Turnstile is read from /api/status on lottery / login pages + 'TurnstileCheckEnabled', + 'TurnstileSiteKey', + 'TurnstileSecretKey', ] export function useUpdateOption() { diff --git a/web/default/src/features/system-settings/types.ts b/web/default/src/features/system-settings/types.ts index 11c51f08adc3..b2f2bff6d85b 100644 --- a/web/default/src/features/system-settings/types.ts +++ b/web/default/src/features/system-settings/types.ts @@ -187,6 +187,22 @@ export type ContentSettings = { MjActionCheckSuccessEnabled: boolean } +export type ExtensionsSettings = { + 'console_setting.custom_pages': string + 'console_setting.availability_monitor_enabled': boolean + 'console_setting.availability_monitor_visibility': string + 'console_setting.availability_monitor_refresh_interval': number + 'lottery_setting.enabled': boolean + 'lottery_setting.daily_pool_usd': number + 'lottery_setting.display_daily_pool_usd': number + 'lottery_setting.min_bet_usd': number + 'lottery_setting.max_bet_usd': number + 'lottery_setting.max_draws_per_ip_per_day': number + 'lottery_setting.require_redemption': boolean + 'lottery_setting.free_prizes': string + 'lottery_setting.bet_prizes': string +} + export type ModelSettings = { 'global.pass_through_request_enabled': boolean 'global.thinking_model_blacklist': string diff --git a/web/default/src/hooks/use-sidebar-data.ts b/web/default/src/hooks/use-sidebar-data.ts index 40a0615aa347..7f160a67853f 100644 --- a/web/default/src/hooks/use-sidebar-data.ts +++ b/web/default/src/hooks/use-sidebar-data.ts @@ -18,8 +18,10 @@ For commercial licensing, please contact support@quantumnous.com */ import { Activity, + ActivitySquare, Box, CreditCard, + Dices, FileText, FlaskConical, Key, @@ -34,9 +36,15 @@ import { Users, Wallet, } from 'lucide-react' +import { useMemo } from 'react' import { useTranslation } from 'react-i18next' -import { type SidebarData } from '@/components/layout/types' +import type { NavGroup, NavItem, SidebarData } from '@/components/layout/types' +import { + resolveCustomPageIcon, + type CustomPageStatusItem, +} from '@/features/system-settings/extensions/constants' +import { useStatus } from '@/hooks/use-status' import { ROLE } from '@/lib/roles' /** @@ -47,117 +55,163 @@ import { ROLE } from '@/lib/roles' */ export function useSidebarData(): SidebarData { const { t } = useTranslation() + const { status } = useStatus() + + const extensionsGroup = useMemo((): NavGroup | null => { + const pages = (status?.custom_pages ?? + status?.data?.custom_pages) as CustomPageStatusItem[] | undefined + const monitorVisible = Boolean( + status?.availability_monitor_visible ?? + status?.data?.availability_monitor_visible + ) + const lotteryEnabled = Boolean( + status?.lottery_enabled ?? status?.data?.lottery_enabled + ) + const items: NavItem[] = [] + if (lotteryEnabled) { + items.push({ + title: t('Lucky Slot'), + url: '/extensions/lottery', + icon: Dices, + }) + } + if (monitorVisible) { + items.push({ + title: t('Availability Monitor'), + url: '/extensions/availability', + icon: ActivitySquare, + }) + } + if (Array.isArray(pages)) { + for (const page of pages) { + items.push({ + title: page.title, + url: `/custom-pages/${page.id}`, + icon: resolveCustomPageIcon(page.icon), + }) + } + } + if (items.length === 0) { + return null + } + return { + id: 'extensions', + title: t('Extensions'), + items, + } + }, [status, t]) + + const navGroups: NavGroup[] = [ + { + id: 'chat', + title: t('Chat'), + items: [ + { + title: t('Playground'), + url: '/playground', + icon: FlaskConical, + }, + { + title: t('Chat'), + icon: MessageSquare, + type: 'chat-presets', + }, + ], + }, + { + id: 'general', + title: t('General'), + items: [ + { + title: t('Overview'), + url: '/dashboard/overview', + icon: Activity, + }, + { + title: t('Dashboard'), + url: '/dashboard/models', + icon: LayoutDashboard, + }, + { + title: t('API Keys'), + url: '/keys', + icon: Key, + }, + { + title: t('Usage Logs'), + url: '/usage-logs/common', + icon: FileText, + }, + { + title: t('Task Logs'), + url: '/usage-logs/task', + activeUrls: ['/usage-logs/drawing'], + configUrls: ['/usage-logs/drawing', '/usage-logs/task'], + icon: ListTodo, + }, + ], + }, + { + id: 'personal', + title: t('Personal'), + items: [ + { + title: t('Wallet'), + url: '/wallet', + icon: Wallet, + }, + { + title: t('Profile'), + url: '/profile', + icon: User, + }, + ], + }, + ...(extensionsGroup ? [extensionsGroup] : []), + { + id: 'admin', + title: t('Admin'), + items: [ + { + title: t('Channels'), + url: '/channels', + icon: Radio, + }, + { + title: t('Models'), + url: '/models/metadata', + icon: Box, + }, + { + title: t('Users'), + url: '/users', + icon: Users, + }, + { + title: t('Redemption Codes'), + url: '/redemption-codes', + icon: Ticket, + }, + { + title: t('Subscriptions'), + url: '/subscriptions', + icon: CreditCard, + }, + { + title: t('System Info'), + url: '/system-info', + icon: ServerCog, + requiredRole: ROLE.SUPER_ADMIN, + }, + { + title: t('System Settings'), + url: '/system-settings/site', + activeUrls: ['/system-settings'], + icon: Settings, + }, + ], + }, + ] - return { - navGroups: [ - { - id: 'chat', - title: t('Chat'), - items: [ - { - title: t('Playground'), - url: '/playground', - icon: FlaskConical, - }, - { - title: t('Chat'), - icon: MessageSquare, - type: 'chat-presets', - }, - ], - }, - { - id: 'general', - title: t('General'), - items: [ - { - title: t('Overview'), - url: '/dashboard/overview', - icon: Activity, - }, - { - title: t('Dashboard'), - url: '/dashboard/models', - icon: LayoutDashboard, - }, - { - title: t('API Keys'), - url: '/keys', - icon: Key, - }, - { - title: t('Usage Logs'), - url: '/usage-logs/common', - icon: FileText, - }, - { - title: t('Task Logs'), - url: '/usage-logs/task', - activeUrls: ['/usage-logs/drawing'], - configUrls: ['/usage-logs/drawing', '/usage-logs/task'], - icon: ListTodo, - }, - ], - }, - { - id: 'personal', - title: t('Personal'), - items: [ - { - title: t('Wallet'), - url: '/wallet', - icon: Wallet, - }, - { - title: t('Profile'), - url: '/profile', - icon: User, - }, - ], - }, - { - id: 'admin', - title: t('Admin'), - items: [ - { - title: t('Channels'), - url: '/channels', - icon: Radio, - }, - { - title: t('Models'), - url: '/models/metadata', - icon: Box, - }, - { - title: t('Users'), - url: '/users', - icon: Users, - }, - { - title: t('Redemption Codes'), - url: '/redemption-codes', - icon: Ticket, - }, - { - title: t('Subscriptions'), - url: '/subscriptions', - icon: CreditCard, - }, - { - title: t('System Info'), - url: '/system-info', - icon: ServerCog, - requiredRole: ROLE.SUPER_ADMIN, - }, - { - title: t('System Settings'), - url: '/system-settings/site', - activeUrls: ['/system-settings'], - icon: Settings, - }, - ], - }, - ], - } + return { navGroups } } diff --git a/web/default/src/i18n/locales/en.json b/web/default/src/i18n/locales/en.json index 45dde3cb7333..2fd2be012187 100644 --- a/web/default/src/i18n/locales/en.json +++ b/web/default/src/i18n/locales/en.json @@ -38,6 +38,8 @@ "{{count}} channel(s) enabled": "{{count}} channel(s) enabled", "{{count}} channel(s) failed to disable": "{{count}} channel(s) failed to disable", "{{count}} channel(s) failed to enable": "{{count}} channel(s) failed to enable", + "{{count}} custom pages deleted. Click \"Save Settings\" to apply.": "{{count}} custom pages deleted. Click \"Save Settings\" to apply.", + "{{count}} custom pages will be removed from the list.": "{{count}} custom pages will be removed from the list.", "{{count}} days ago": "{{count}} days ago", "{{count}} days remaining": "{{count}} days remaining", "{{count}} disabled channel(s) deleted": "{{count}} disabled channel(s) deleted", @@ -82,6 +84,7 @@ "+{{count}} more": "+{{count}} more", "| Based on": "| Based on", "0 means data is kept permanently": "0 means data is kept permanently", + "0 means no IP limit.": "0 means no IP limit.", "0 means unlimited": "0 means unlimited", "1 Day": "1 Day", "1 day ago": "1 day ago", @@ -118,6 +121,8 @@ "80,443,8080": "80,443,8080", "A billing multiplier. Lower ratios mean lower API call costs.": "A billing multiplier. Lower ratios mean lower API call costs.", "A focused home for keys, balance, routing, and service health.": "A focused home for keys, balance, routing, and service health.", + "A recommended model is selected automatically. You can change it.": "A recommended model is selected automatically. You can change it.", + "Abnormal": "Abnormal", "About": "About", "About {{days}} days left": "About {{days}} days left", "Accept Unpriced Models": "Accept Unpriced Models", @@ -174,6 +179,7 @@ "Add Condition": "Add Condition", "Add credits": "Add credits", "Add custom model \"{{value}}\"": "Add custom model \"{{value}}\"", + "Add Custom Page": "Add Custom Page", "Add discount tier": "Add discount tier", "Add each model or tag you want to include.": "Add each model or tag you want to include.", "Add FAQ": "Add FAQ", @@ -197,6 +203,7 @@ "Add param/header": "Add param/header", "Add payment method": "Add payment method", "Add photos or files": "Add photos or files", + "Add prize": "Add prize", "Add product": "Add product", "Add Provider": "Add Provider", "Add Quota": "Add Quota", @@ -239,6 +246,7 @@ "Administer user accounts and roles.": "Administer user accounts and roles.", "Administrator account": "Administrator account", "Administrator username": "Administrator username", + "Admins only": "Admins only", "Advance next reset time": "Advance next reset time", "Advanced": "Advanced", "Advanced Configuration": "Advanced Configuration", @@ -339,7 +347,10 @@ "Allowed": "Allowed", "Allowed Origins": "Allowed Origins", "Allowed Ports": "Allowed Ports", + "Allowed: {{min}} – {{max}}": "Allowed: {{min}} – {{max}}", + "Allowed: ${{min}} – ${{max}}": "Allowed: ${{min}} – ${{max}}", "Already have an account?": "Already have an account?", + "Already spun today": "Already spun today", "Always matches (default tier).": "Always matches (default tier).", "Amount": "Amount", "Amount cannot be changed when editing.": "Amount cannot be changed when editing.", @@ -387,6 +398,7 @@ "API Key (Sandbox)": "API Key (Sandbox)", "API Key *": "API Key *", "API Key created successfully": "API Key created successfully", + "API key created. Opening the selected tool...": "API key created. Opening the selected tool...", "API Key deleted successfully": "API Key deleted successfully", "API Key disabled successfully": "API Key disabled successfully", "API Key enabled successfully": "API Key enabled successfully", @@ -449,6 +461,10 @@ "Are you sure?": "Are you sure?", "Area Chart": "Area Chart", "Args (space separated)": "Args (space separated)", + "Array of {name, multiplier, weight, is_thanks}. Multiplier must be within [-1, 2].": "Array of {name, multiplier, weight, is_thanks}. Multiplier must be within [-1, 2].", + "Array of {name, multiplier, weight, is_thanks}. Multiplier must be within [-1, 2]. Bet amount itself is in USD.": "Array of {name, multiplier, weight, is_thanks}. Multiplier must be within [-1, 2]. Bet amount itself is in USD.", + "Array of {name, quota, weight, is_thanks}. Higher quota should use lower weight.": "Array of {name, quota, weight, is_thanks}. Higher quota should use lower weight.", + "Array of {name, usd, weight, is_thanks}. usd is dollars. Higher usd should use lower weight.": "Array of {name, usd, weight, is_thanks}. usd is dollars. Higher usd should use lower weight.", "Array of chat client presets. Each item is an object with one key-value pair: client name and its URL.": "Array of chat client presets. Each item is an object with one key-value pair: client name and its URL.", "Asc": "Asc", "Ask anything": "Ask anything", @@ -520,7 +536,9 @@ "Automatically replaces upstream callback URLs with the server address.": "Automatically replaces upstream callback URLs with the server address.", "Automatically selects the best available group with circuit breaker mechanism": "Automatically selects the best available group with circuit breaker mechanism", "Automatically sync model list when upstream changes are detected": "Automatically sync model list when upstream changes are detected", + "Availability": "Availability", "Availability (last 24h)": "Availability (last 24h)", + "Availability Monitor": "Availability Monitor", "Available": "Available", "Available credits are ordered by soonest expiration.": "Available credits are ordered by soonest expiration.", "Available disk space": "Available disk space", @@ -535,6 +553,7 @@ "Average tokens per second sustained per group": "Average tokens per second sustained per group", "Average TPM": "Average TPM", "Average TTFT": "Average TTFT", + "Avg latency": "Avg latency", "AWS": "AWS", "AWS Bedrock Claude Compat": "AWS Bedrock Claude Compat", "AWS Key Format": "AWS Key Format", @@ -558,6 +577,7 @@ "Baidu V2": "Baidu V2", "Balance": "Balance", "Balance and top-up management": "Balance and top-up management", + "Balance change": "Balance change", "Balance depleted": "Balance depleted", "Balance is shown in quota units": "Balance is shown in quota units", "Balance queried successfully": "Balance queried successfully", @@ -604,6 +624,15 @@ "Batch upstream model updates applied: {{channels}} channels, {{added}} added, {{removed}} removed, {{fails}} failed": "Batch upstream model updates applied: {{channels}} channels, {{added}} added, {{removed}} removed, {{fails}} failed", "Best for single-tenant deployments. Pricing and billing options stay hidden.": "Best for single-tenant deployments. Pricing and billing options stay hidden.", "Best TTFT": "Best TTFT", + "Bet amount": "Bet amount", + "Bet amount (USD)": "Bet amount (USD)", + "Bet amount is out of range": "Bet amount is out of range", + "Bet cannot exceed your current balance": "Bet cannot exceed your current balance", + "Bet cannot exceed your current quota": "Bet cannot exceed your current quota", + "Bet mode prizes": "Bet mode prizes", + "Bet prizes JSON": "Bet prizes JSON", + "Bet with quota": "Bet with quota", + "Bet with USD": "Bet with USD", "Billable input tokens": "Billable input tokens", "Billable output tokens": "Billable output tokens", "Billed as default. No cell for this combination, so the base ratio of default applies — the 0.8 of vip plays no part.": "Billed as default. No cell for this combination, so the base ratio of default applies — the 0.8 of vip plays no part.", @@ -814,6 +843,8 @@ "Choose the default charts, range, and time granularity for model analytics.": "Choose the default charts, range, and time granularity for model analytics.", "Choose where to fetch upstream metadata.": "Choose where to fetch upstream metadata.", "Choose which charts are selected by default when opening model analytics.": "Choose which charts are selected by default when opening model analytics.", + "Choose who can see the Availability Monitor entry in the Extensions sidebar.": "Choose who can see the Availability Monitor entry in the Extensions sidebar.", + "Choose who can see this page in the Extensions sidebar.": "Choose who can see this page in the Extensions sidebar.", "Clamped to": "Clamped to", "Classic (Legacy Frontend)": "Classic (Legacy Frontend)", "Claude": "Claude", @@ -949,6 +980,7 @@ "Configuration for Epay payment integration": "Configuration for Epay payment integration", "Configuration for Stripe payment integration": "Configuration for Stripe payment integration", "Configuration required": "Configuration required", + "Configuration tool": "Configuration tool", "Configure": "Configure", "Configure a Creem product for user recharge options.": "Configure a Creem product for user recharge options.", "Configure a custom ratio for when users use a specific token group.": "Configure a custom ratio for when users use a specific token group.", @@ -974,6 +1006,8 @@ "Configure rate limiting rules for a specific user group.": "Configure rate limiting rules for a specific user group.", "Configure routes": "Configure routes", "Configure the ratio for this group.": "Configure the ratio for this group.", + "Configure the sidebar title, icon, embed URL, status, and sort order.": "Configure the sidebar title, icon, embed URL, status, and sort order.", + "Configure the sidebar title, icon, URL, open mode, status, and sort order.": "Configure the sidebar title, icon, URL, open mode, status, and sort order.", "Configure upstream providers and routing.": "Configure upstream providers and routing.", "Configure Waffo Pancake hosted checkout integration for USD-priced top-ups": "Configure Waffo Pancake hosted checkout integration for USD-priced top-ups", "Configure Waffo payment aggregation platform integration": "Configure Waffo payment aggregation platform integration", @@ -981,6 +1015,7 @@ "Configure your account preferences and integrations": "Configure your account preferences and integrations", "Configured as PayMethods JSON. The type value decides which payment flow is used: stripe for Stripe, waffo_pancake for Waffo Pancake, and other values are sent to Epay as the type parameter.": "Configured as PayMethods JSON. The type value decides which payment flow is used: stripe for Stripe, waffo_pancake for Waffo Pancake, and other values are sent to Epay as the type parameter.", "Configured routes and latency checks": "Configured routes and latency checks", + "Configuring...": "Configuring...", "Confirm": "Confirm", "Confirm Action": "Confirm Action", "Confirm and enable": "Confirm and enable", @@ -1015,6 +1050,7 @@ "Conflict": "Conflict", "Connect": "Connect", "Connect through OpenAI, Claude, Gemini, and other compatible API routes": "Connect through OpenAI, Claude, Gemini, and other compatible API routes", + "Connect tool": "Connect tool", "Connected to io.net service normally.": "Connected to io.net service normally.", "Connection closed": "Connection closed", "Connection error": "Connection error", @@ -1115,10 +1151,12 @@ "Cost = model price × that one ratio. Nothing else from the group settings enters the formula.": "Cost = model price × that one ratio. Nothing else from the group settings enters the formula.", "Cost in USD per request, regardless of tokens used.": "Cost in USD per request, regardless of tokens used.", "Cost Tracking": "Cost Tracking", + "Could not load pricing data. Open the pricing page or refresh and try again.": "Could not load pricing data. Open the pricing page or refresh and try again.", "Count must be between {{min}} and {{max}}": "Count must be between {{min}} and {{max}}", "Coze": "Coze", "CPU": "CPU", "CPU Threshold (%)": "CPU Threshold (%)", + "Crazy Thursday!": "Crazy Thursday!", "Create": "Create", "Create a copy of:": "Create a copy of:", "Create a key for your app or service": "Create a key for your app or service", @@ -1126,6 +1164,7 @@ "Create account": "Create account", "Create an account": "Create an account", "Create an API key to unlock the real request": "Create an API key to unlock the real request", + "Create and configure": "Create and configure", "Create and review invite or credit codes.": "Create and review invite or credit codes.", "Create API Key": "Create API Key", "Create cache": "Create cache", @@ -1214,6 +1253,12 @@ "Custom multipliers when specific user groups use specific token groups. Example: VIP users get 0.9x rate when using \"edit_this\" group tokens.": "Custom multipliers when specific user groups use specific token groups. Example: VIP users get 0.9x rate when using \"edit_this\" group tokens.", "Custom OAuth": "Custom OAuth", "Custom OAuth Providers": "Custom OAuth Providers", + "Custom page added. Click \"Save Settings\" to apply.": "Custom page added. Click \"Save Settings\" to apply.", + "Custom page deleted. Click \"Save Settings\" to apply.": "Custom page deleted. Click \"Save Settings\" to apply.", + "Custom page not found": "Custom page not found", + "Custom page updated. Click \"Save Settings\" to apply.": "Custom page updated. Click \"Save Settings\" to apply.", + "Custom Pages": "Custom Pages", + "Custom pages saved successfully": "Custom pages saved successfully", "Custom Seconds": "Custom Seconds", "Custom sidebar section": "Custom sidebar section", "Custom Time Range": "Custom Time Range", @@ -1221,6 +1266,12 @@ "Customize sidebar display content": "Customize sidebar display content", "Daily": "Daily", "Daily Check-in": "Daily Check-in", + "Daily prize pool": "Daily prize pool", + "Daily prize pool (USD)": "Daily prize pool (USD)", + "Display daily prize pool (USD)": "Display daily prize pool (USD)", + "Shown to users on the lottery page. Does not limit real payouts. Doubled on Thursdays. Set 0 to fall back to the actual pool.": "Shown to users on the lottery page. Does not limit real payouts. Doubled on Thursdays. Set 0 to fall back to the actual pool.", + "Actual daily pool limit (USD)": "Actual daily pool limit (USD)", + "Real daily payout cap used by the backend. Hidden from users. Doubled on Thursdays.": "Real daily payout cap used by the backend. Hidden from users. Doubled on Thursdays.", "Daily token usage by model across the past few weeks": "Daily token usage by model across the past few weeks", "Daily token usage by model across the past month": "Daily token usage by model across the past month", "Daily token usage by model over the past month": "Daily token usage by model over the past month", @@ -1428,7 +1479,9 @@ "Do not wait one second between polling async tasks for this channel": "Do not wait one second between polling async tasks for this channel", "Do regex replacement in the target field": "Do regex replacement in the target field", "Do string replacement in the target field": "Do string replacement in the target field", + "Do you want to download the created redemption codes as a text file?": "Do you want to download the created redemption codes as a text file?", "Docs": "Docs", + "Documentation": "Documentation", "Documentation Link": "Documentation Link", "Documentation or external knowledge base.": "Documentation or external knowledge base.", "does not exist or might have been removed.": "does not exist or might have been removed.", @@ -1439,6 +1492,7 @@ "Doubao custom API address editing unlocked": "Doubao custom API address editing unlocked", "DoubaoVideo": "DoubaoVideo", "Double check the configuration below. Your system will be locked until initialization is complete.": "Double check the configuration below. Your system will be locked until initialization is complete.", + "Doubled automatically on Thursdays.": "Doubled automatically on Thursdays.", "Downgrade Group": "Downgrade Group", "Downgrade to pre-purchase group": "Downgrade to pre-purchase group", "Downgrade to this group after the subscription expires": "Downgrade to this group after the subscription expires", @@ -1507,6 +1561,7 @@ "Each item must have exactly one key-value pair.": "Each item must have exactly one key-value pair.", "Each line represents one keyword. Leave blank to disable the list but keep the switch states.": "Each line represents one keyword. Leave blank to disable the list but keep the switch states.", "Each matrix cell is one rule: users of this row group pay this ratio when billed as this column group. In JSON the row is the outer key and the column is the inner key.": "Each matrix cell is one rule: users of this row group pay this ratio when billed as this column group. In JSON the row is the outer key and the column is the inner key.", + "Each row is a prize. Higher USD should usually have lower weight.": "Each row is a prize. Higher USD should usually have lower weight.", "Each rule reads as a sentence: users of one group pay a special ratio when billed as another group. Without a rule, the billing group base ratio applies.": "Each rule reads as a sentence: users of one group pay a special ratio when billed as another group. Without a rule, the billing group base ratio applies.", "Each tier supports 0~2 conditions (over len, p, c); the last tier is the catch-all without conditions. Use len (full input length, including cache hits) for tier conditions to avoid mis-routing when cache hits reduce p.": "Each tier supports 0~2 conditions (over len, p, c); the last tier is the catch-all without conditions. Use len (full input length, including cache hits) for tier conditions to avoid mis-routing when cache hits reduce p.", "Each tier supports up to 2 conditions; the last tier is the catch-all without conditions. Use full input length for tier conditions to avoid mis-routing when cache hits reduce billable input tokens.": "Each tier supports up to 2 conditions; the last tier is the catch-all without conditions. Use full input length for tier conditions to avoid mis-routing when cache hits reduce billable input tokens.", @@ -1521,6 +1576,7 @@ "Edit Channel": "Edit Channel", "Edit channel routing": "Edit channel routing", "Edit chat preset": "Edit chat preset", + "Edit Custom Page": "Edit Custom Page", "Edit discount tier": "Edit discount tier", "Edit FAQ": "Edit FAQ", "Edit group": "Edit group", @@ -1557,6 +1613,7 @@ "Email Field": "Email Field", "Email Verification": "Email Verification", "Email, summarisation, knowledge work": "Email, summarisation, knowledge work", + "Embed in console": "Embed in console", "Embeddings": "Embeddings", "Empty": "Empty", "Empty value will be saved as {}.": "Empty value will be saved as {}.", @@ -1564,6 +1621,7 @@ "Enable {{parameter}}": "Enable {{parameter}}", "Enable 2FA": "Enable 2FA", "Enable All": "Enable All", + "Enable availability monitor": "Enable availability monitor", "Enable check-in feature": "Enable check-in feature", "Enable Data Dashboard": "Enable Data Dashboard", "Enable demo mode with limited functionality": "Enable demo mode with limited functionality", @@ -1578,6 +1636,7 @@ "Enable io.net deployments": "Enable io.net deployments", "Enable io.net model deployment service in console": "Enable io.net model deployment service in console", "Enable LinuxDO OAuth": "Enable LinuxDO OAuth", + "Enable lucky slot": "Enable lucky slot", "Enable model performance metrics": "Enable model performance metrics", "Enable OIDC": "Enable OIDC", "Enable or disable this channel": "Enable or disable this channel", @@ -1606,6 +1665,7 @@ "Enabled": "Enabled", "Enabled all channels with tag: {{tag}}": "Enabled all channels with tag: {{tag}}", "Enabled channels with tag {{tag}}": "Enabled channels with tag {{tag}}", + "Enabled pages with a URL appear under the Extensions group in the console sidebar and open as embedded pages.": "Enabled pages with a URL appear under the Extensions group in the console sidebar and open as embedded pages.", "Enabled Status": "Enabled Status", "Enabling...": "Enabling...", "Encourages introducing new topics": "Encourages introducing new topics", @@ -1721,6 +1781,7 @@ "Estimated cost": "Estimated cost", "Estimated quota cost": "Estimated quota cost", "Every group name in the pricing table can be used in two places: on a user (the user group, assigned by admins) and on a token (the token group, chosen when creating the token). Same name pool, two different jobs.": "Every group name in the pricing table can be used in two places: on a user (the user group, assigned by admins) and on a token (the token group, chosen when creating the token). Same name pool, two different jobs.", + "Everyone": "Everyone", "Everything configured for this group, in one place.": "Everything configured for this group, in one place.", "Exact": "Exact", "Exact Match": "Exact Match", @@ -1765,6 +1826,7 @@ "Extend deployment": "Extend deployment", "Extend failed": "Extend failed", "Extended successfully": "Extended successfully", + "Extensions": "Extensions", "External Device": "External Device", "External link for users to purchase quota": "External link for users to purchase quota", "External operations": "External operations", @@ -1843,12 +1905,14 @@ "Failed to initialize system": "Failed to initialize system", "Failed to load": "Failed to load", "Failed to load API keys": "Failed to load API keys", + "Failed to load availability": "Failed to load availability", "Failed to load billing history": "Failed to load billing history", "Failed to load enabled models": "Failed to load enabled models", "Failed to load home page content": "Failed to load home page content", "Failed to load image": "Failed to load image", "Failed to load key status": "Failed to load key status", "Failed to load logs": "Failed to load logs", + "Failed to load lottery status": "Failed to load lottery status", "Failed to load Passkey status": "Failed to load Passkey status", "Failed to load playground groups": "Failed to load playground groups", "Failed to load playground models": "Failed to load playground models", @@ -1874,7 +1938,9 @@ "Failed to save": "Failed to save", "Failed to save announcements": "Failed to save announcements", "Failed to save API info": "Failed to save API info", + "Failed to save custom pages": "Failed to save custom pages", "Failed to save FAQ": "Failed to save FAQ", + "Failed to save settings": "Failed to save settings", "Failed to save Uptime Kuma groups": "Failed to save Uptime Kuma groups", "Failed to search API keys": "Failed to search API keys", "Failed to search redemption codes": "Failed to search redemption codes", @@ -1965,8 +2031,8 @@ "Filter by MjProxy task ID": "Filter by MjProxy task ID", "Filter by model name...": "Filter by model name...", "Filter by model...": "Filter by model...", - "Filter by name or ID...": "Filter by name or ID...", "Filter by name, ID, or key...": "Filter by name, ID, or key...", + "Filter by name, ID, or redemption code...": "Filter by name, ID, or redemption code...", "Filter by name...": "Filter by name...", "Filter by node": "Filter by node", "Filter by price field": "Filter by price field", @@ -2024,7 +2090,7 @@ "footer.columns.related.links.oneApi": "One API", "footer.columns.related.title": "Related Projects", "footer.defaultCopyright": "All rights reserved.", - "footer.new\u0061pi.projectAttributionSuffix": "All rights reserved. Designed and developed by the project contributors.", + "footer.newapi.projectAttributionSuffix": "All rights reserved. Designed and developed by the project contributors.", "For channels added after May 10, 2025, no need to remove \".\" from model names during deployment": "For channels added after May 10, 2025, no need to remove \".\" from model names during deployment", "For private deployments, format: https://fastgpt.run/api/openapi": "For private deployments, format: https://fastgpt.run/api/openapi", "Force a syntactically valid JSON response": "Force a syntactically valid JSON response", @@ -2051,6 +2117,9 @@ "Forward requests directly to upstream providers without any post-processing.": "Forward requests directly to upstream providers without any post-processing.", "Frames per second": "Frames per second", "Free": "Free", + "Free mode prizes": "Free mode prizes", + "Free prizes JSON": "Free prizes JSON", + "Free prizes JSON (USD)": "Free prizes JSON (USD)", "Free: {{free}} / Total: {{total}}": "Free: {{free}} / Total: {{total}}", "Frequency Penalty": "Frequency Penalty", "Friendly name to identify this channel": "Friendly name to identify this channel", @@ -2112,6 +2181,8 @@ "Go to settings": "Go to settings", "Go to Settings": "Go to Settings", "Good": "Good", + "Got it": "Got it", + "Congratulations!": "Congratulations!", "Gotify Application Token": "Gotify Application Token", "Gotify Documentation": "Gotify Documentation", "Gotify Server URL": "Gotify Server URL", @@ -2216,6 +2287,7 @@ "How It Works": "How It Works", "How model mapping works": "How model mapping works", "How much to charge for each US dollar of balance (Epay)": "How much to charge for each US dollar of balance (Epay)", + "How often the Availability Monitor page automatically reloads data. Allowed range: 5–3600 seconds.": "How often the Availability Monitor page automatically reloads data. Allowed range: 5–3600 seconds.", "How this model name should match requests": "How this model name should match requests", "How to deliver the resulting image": "How to deliver the resulting image", "How to get an io.net API Key": "How to get an io.net API Key", @@ -2257,6 +2329,7 @@ "If connecting to upstream One API or New API relay projects, use OpenAI type instead unless you know what you are doing": "If connecting to upstream One API or New API relay projects, use OpenAI type instead unless you know what you are doing", "If default auto group is enabled, newly created tokens start with auto instead of an empty group.": "If default auto group is enabled, newly created tokens start with auto instead of an empty group.", "If the affinity channel fails and retry succeeds on another channel, update affinity to the successful channel.": "If the affinity channel fails and retry succeeds on another channel, update affinity to the successful channel.", + "If the app did not open, install the tool and use this API key manually:": "If the app did not open, install the tool and use this API key manually:", "If this keeps happening, please report it on GitHub Issues.": "If this keeps happening, please report it on GitHub Issues.", "If you provide generative AI services to the public in mainland China, you will fulfill legal obligations including filing, security assessment, content safety, complaint handling, generated content labeling, log retention, and personal information protection.": "If you provide generative AI services to the public in mainland China, you will fulfill legal obligations including filing, security assessment, content safety, complaint handling, generated content labeling, log retention, and personal information protection.", "Ignore": "Ignore", @@ -2487,6 +2560,7 @@ "Load template...": "Load template...", "Loader": "Loader", "Loading": "Loading", + "Loading available providers...": "Loading available providers...", "Loading channel details": "Loading channel details", "Loading configuration": "Loading configuration", "Loading content settings...": "Loading content settings...", @@ -2526,8 +2600,13 @@ "Logo URL": "Logo URL", "Logs": "Logs", "Look for a special ratio rule matching this user group and this billing group. If one exists, use its ratio. Otherwise use the billing group base ratio from the pricing table.": "Look for a special ratio rule matching this user group and this billing group. If one exists, use its ratio. Otherwise use the billing group base ratio from the pricing table.", + "Lottery draw failed": "Lottery draw failed", "Low balance": "Low balance", + "Lower numbers appear first in the sidebar.": "Lower numbers appear first in the sidebar.", "Lowest median first-token latency": "Lowest median first-token latency", + "Lucky Slot": "Lucky Slot", + "Lucky Slot Lottery": "Lucky Slot Lottery", + "Lucky Slot Machine": "Lucky Slot Machine", "m": "m", "Maintenance": "Maintenance", "Make extra groups visible to, or hide default groups from, users of a specific group.": "Make extra groups visible to, or hide default groups from, users of a specific group.", @@ -2572,7 +2651,10 @@ "Matched Tier": "Matched Tier", "Matches models not claimed by earlier splits.": "Matches models not claimed by earlier splits.", "Matching Rules": "Matching Rules", + "Max bet": "Max bet", + "Max bet (USD)": "Max bet (USD)", "Max Disk Cache Size (MB)": "Max Disk Cache Size (MB)", + "Max draws per IP / day": "Max draws per IP / day", "Max Entries": "Max Entries", "Max output": "Max output", "Max Requests (incl. failures)": "Max Requests (incl. failures)", @@ -2607,6 +2689,13 @@ "Merge into Other": "Merge into Other", "Message Priority": "Message Priority", "Metadata": "Metadata", + "Min bet": "Min bet", + "Min bet (USD)": "Min bet (USD)", + "Min bet cannot exceed max bet": "Min bet cannot exceed max bet", + "Require redemption code to play": "Require redemption code to play", + "Redeem code required": "Redeem code required", + "Please redeem a code before playing. Crazy Thursday does not require this.": "Please redeem a code before playing. Crazy Thursday does not require this.", + "On normal days, users must have redeemed at least one code. Crazy Thursday skips this requirement.": "On normal days, users must have redeemed at least one code. Crazy Thursday skips this requirement.", "min downtime": "min downtime", "Min Top-up": "Min Top-up", "Min Top-up:": "Min Top-up:", @@ -2768,9 +2857,11 @@ "Multiplier for completion tokens.": "Multiplier for completion tokens.", "Multiplier for image processing.": "Multiplier for image processing.", "Multiplier for prompt tokens.": "Multiplier for prompt tokens.", + "Multiplier is relative to bet amount. Range: -1 to 2.": "Multiplier is relative to bet amount. Range: -1 to 2.", "Multipliers for recharge pricing based on user groups.": "Multipliers for recharge pricing based on user groups.", "Must be a valid URL": "Must be a valid URL", "Must be at least 8 characters": "Must be at least 8 characters", + "Must be http(s). Leave empty to keep the page hidden from the sidebar.": "Must be http(s). Leave empty to keep the page hidden from the sidebar.", "My Subscriptions": "My Subscriptions", "my-status": "my-status", "MySQL detected": "MySQL detected", @@ -2812,6 +2903,7 @@ "New password": "New password", "New Password": "New Password", "New password must be different from current password": "New password must be different from current password", + "New prize": "New prize", "New User Quota": "New User Quota", "New version available: {{version}}": "New version available: {{version}}", "NewAPI": "NewAPI", @@ -2841,6 +2933,7 @@ "No available Web chat links": "No available Web chat links", "No backup": "No backup", "No base input price": "No base input price", + "No billing groups configured.": "No billing groups configured.", "No billing records found": "No billing records found", "No capabilities reported for this model.": "No capabilities reported for this model.", "No Change": "No Change", @@ -2862,6 +2955,7 @@ "No containers": "No containers", "No content to copy": "No content to copy", "No custom OAuth providers configured yet.": "No custom OAuth providers configured yet.", + "No custom pages yet. Click \"Add Custom Page\" to create one.": "No custom pages yet. Click \"Add Custom Page\" to create one.", "No data": "No data", "No Data": "No Data", "No data available": "No data available", @@ -2880,6 +2974,7 @@ "No group": "No group", "No group found.": "No group found.", "No group-based rate limits configured. Click \"Add group\" to get started.": "No group-based rate limits configured. Click \"Add group\" to get started.", + "No groups available for this provider type": "No groups available for this provider type", "No groups match your search": "No groups match your search", "No groups yet. Add a group to get started.": "No groups yet. Add a group to get started.", "No header overrides configured.": "No header overrides configured.", @@ -2942,9 +3037,13 @@ "No processable upstream model updates for this channel": "No processable upstream model updates for this channel", "No products configured. Click \"Add product\" to get started.": "No products configured. Click \"Add product\" to get started.", "No products match your search": "No products match your search", + "No provider types are available for your current groups.": "No provider types are available for your current groups.", + "No provider types are available. Types unlock when pricing models match Anthropic / OpenAI / Gemini / xAI for your groups — having channels alone is not enough.": "No provider types are available. Types unlock when pricing models match Anthropic / OpenAI / Gemini / xAI for your groups — having channels alone is not enough.", + "No provider types are available. Types unlock when pricing models match Anthropic / OpenAI for your groups — having channels alone is not enough.": "No provider types are available. Types unlock when pricing models match Anthropic / OpenAI for your groups — having channels alone is not enough.", "No providers available": "No providers available", "No Quota": "No Quota", "No ratio differences found": "No ratio differences found", + "No recent requests for this group.": "No recent requests for this group.", "No recent usage": "No recent usage", "No records found. Try adjusting your filters.": "No records found. Try adjusting your filters.", "No redemption codes available. Create your first redemption code to get started.": "No redemption codes available. Create your first redemption code to get started.", @@ -2996,6 +3095,7 @@ "Non-zero invitation rewards require compliance confirmation in Payment Gateway settings.": "Non-zero invitation rewards require compliance confirmation in Payment Gateway settings.", "None": "None", "noreply@example.com": "noreply@example.com", + "Normal": "Normal", "Normalized:": "Normalized:", "Not available": "Not available", "Not backed up": "Not backed up", @@ -3016,6 +3116,7 @@ "Notification Email": "Notification Email", "Notification Method": "Notification Method", "Notifications": "Notifications", + "Now": "Now", "Now a user whose user group is vip creates tokens with different groups and makes one call with each:": "Now a user whose user group is vip creates tokens with different groups and makes one call with each:", "Nucleus sampling probability mass": "Nucleus sampling probability mass", "Number of codes to create": "Number of codes to create", @@ -3076,6 +3177,7 @@ "Only available for admins. When enabled, you will receive a summary notification via your selected method when the scheduled model check detects upstream model changes or check failures.": "Only available for admins. When enabled, you will receive a summary notification via your selected method when the scheduled model check detects upstream model changes or check failures.", "Only configured combinations are overridden. All other calls keep the billing group base ratio.": "Only configured combinations are overridden. All other calls keep the billing group base ratio.", "Only configured combinations are overridden. All other calls keep the token group base ratio.": "Only configured combinations are overridden. All other calls keep the token group base ratio.", + "Only enabled pages with a URL are shown in the Extensions sidebar group.": "Only enabled pages with a URL are shown in the Extensions sidebar group.", "Only enabled parameters are sent with the request.": "Only enabled parameters are sent with the request.", "Only enter the site origin, for example https://api.example.com. Do not include any path such as /api/user/epay/notify. Leave blank to use the server address.": "Only enter the site origin, for example https://api.example.com. Do not include any path such as /api/user/epay/notify. Leave blank to use the server address.", "Only Mine": "Only Mine", @@ -3083,6 +3185,7 @@ "Only one OpenAI Models route is allowed": "Only one OpenAI Models route is allowed", "Only selected fields will be overwritten. You can re-run the sync wizard if new conflicts appear.": "Only selected fields will be overwritten. You can re-run the sync wizard if new conflicts appear.", "Only successful requests": "Only successful requests", + "Only recharged users can play. Your quota upper limit must be greater than ${{amount}}.": "Only recharged users can play. Your quota upper limit must be greater than ${{amount}}.", "Only successful requests count toward this limit.": "Only successful requests count toward this limit.", "Only the last {{value}} log files will be retained; the rest will be deleted.": "Only the last {{value}} log files will be retained; the rest will be deleted.", "Oops! Page Not Found!": "Oops! Page Not Found!", @@ -3094,6 +3197,7 @@ "Open in new tab": "Open in new tab", "Open in New Tab": "Open in New Tab", "Open menu": "Open menu", + "Open mode": "Open mode", "Open release": "Open release", "Open source": "Open source", "Open Source": "Open Source", @@ -3142,6 +3246,8 @@ "Optional settings for advanced container configuration.": "Optional settings for advanced container configuration.", "Optional supplementary information (max 100 characters)": "Optional supplementary information (max 100 characters)", "Optional tag for grouping channels": "Optional tag for grouping channels", + "Optional. Max net win is 2x bet; you may also lose balance.": "Optional. Max net win is 2x bet; you may also lose balance.", + "Optional. Max net win is 2x bet; you may also lose quota.": "Optional. Max net win is 2x bet; you may also lose quota.", "Opus Model": "Opus Model", "Or continue with": "Or continue with", "Or enter this key manually:": "Or enter this key manually:", @@ -3253,6 +3359,7 @@ "Password reset: {{password}}": "Password reset: {{password}}", "Passwords do not match": "Passwords do not match", "Passwords don't match.": "Passwords don't match.", + "Past": "Past", "Paste Connection Info": "Paste Connection Info", "Path": "Path", "Path not set": "Path not set", @@ -3326,8 +3433,11 @@ "Personal use": "Personal use", "Personal use mode": "Personal use mode", "Pick a date": "Pick a date", + "Pick a provider type, group, model, and client. We create an API key and open the tool for you.": "Pick a provider type, group, model, and client. We create an API key and open the tool for you.", "Pick or create both a store and a product before saving.": "Pick or create both a store and a product before saving.", "Ping Interval (seconds)": "Ping Interval (seconds)", + "Pity prize triggered": "Pity prize triggered", + "Pity progress": "Pity progress", "Plan": "Plan", "Plan Name": "Plan Name", "Plan Price": "Plan Price", @@ -3342,6 +3452,7 @@ "Playground and chat functions": "Playground and chat functions", "Playground experiments and live conversations.": "Playground experiments and live conversations.", "Please agree to the legal terms first": "Please agree to the legal terms first", + "Please complete the human verification first": "Please complete the human verification first", "Please complete the security check to continue.": "Please complete the security check to continue.", "Please confirm that you understand the consequences": "Please confirm that you understand the consequences", "Please confirm your password": "Please confirm your password", @@ -3486,6 +3597,8 @@ "Priority order for tokens in the auto group. The system tries groups from top to bottom.": "Priority order for tokens in the auto group. The system tries groups from top to bottom.", "Privacy Policy": "Privacy Policy", "Private Deployment URL": "Private Deployment URL", + "Prize JSON is invalid": "Prize JSON is invalid", + "Prize pool and free prize amounts are doubled today. V me 50!": "Prize pool and free prize amounts are doubled today. V me 50!", "Processing OAuth response...": "Processing OAuth response...", "Processing...": "Processing...", "Product": "Product", @@ -3511,6 +3624,11 @@ "Prompt price ($/1M tokens)": "Prompt price ($/1M tokens)", "Proprietary": "Proprietary", "Protect login and registration with Cloudflare Turnstile": "Protect login and registration with Cloudflare Turnstile", + "Protect login, registration and lottery draws with Cloudflare Turnstile": "Protect login, registration and lottery draws with Cloudflare Turnstile", + "Unable to enable Turnstile. Please fill in the Turnstile site key first.": "Unable to enable Turnstile. Please fill in the Turnstile site key first.", + "Public site key from Cloudflare Turnstile. Required for the widget to render.": "Public site key from Cloudflare Turnstile. Required for the widget to render.", + "If already saved, this field shows ********. Leave it unchanged unless you need to replace the secret.": "If already saved, this field shows ********. Leave it unchanged unless you need to replace the secret.", + "No changes to save": "No changes to save", "Provide a JSON object where each key maps to an endpoint definition.": "Provide a JSON object where each key maps to an endpoint definition.", "Provide a valid URL starting with http:// or https://": "Provide a valid URL starting with http:// or https://", "Provide Markdown, HTML, or an external URL for the privacy policy": "Provide Markdown, HTML, or an external URL for the privacy policy", @@ -3522,6 +3640,7 @@ "Provider created successfully": "Provider created successfully", "Provider deleted successfully": "Provider deleted successfully", "Provider Name": "Provider Name", + "Provider type": "Provider type", "Provider type (OpenAI, Anthropic, etc.)": "Provider type (OpenAI, Anthropic, etc.)", "Provider updated successfully": "Provider updated successfully", "Provider-specific endpoint, account, and compatibility settings.": "Provider-specific endpoint, account, and compatibility settings.", @@ -3537,6 +3656,7 @@ "Published:": "Published:", "Pull": "Pull", "Pull model": "Pull model", + "Pull to spin": "Pull to spin", "Pulling...": "Pulling...", "Purchase Limit": "Purchase Limit", "Purchase limit reached": "Purchase limit reached", @@ -3559,6 +3679,7 @@ "Quota": "Quota", "Quota ({{currency}})": "Quota ({{currency}})", "Quota adjusted successfully": "Quota adjusted successfully", + "Quota change": "Quota change", "Quota clamped": "Quota clamped", "Quota consumed before charging users": "Quota consumed before charging users", "Quota Distribution": "Quota Distribution", @@ -3613,6 +3734,7 @@ "Receive Upstream Model Update Notifications": "Receive Upstream Model Update Notifications", "Received": "Received", "Received amount": "Received amount", + "Recent {{count}} records": "Recent {{count}} records", "Recent maintenance tasks running across instances and their execution status.": "Recent maintenance tasks running across instances and their execution status.", "Recently completed or failed system task runs.": "Recently completed or failed system task runs.", "Recently launched models": "Recently launched models", @@ -3620,6 +3742,7 @@ "Recharge": "Recharge", "Recharge Amount": "Recharge Amount", "Recharge Amount (USD)": "Recharge Amount (USD)", + "Recharge required": "Recharge required", "Recommended": "Recommended", "Recommended actions": "Recommended actions", "Recommended to keep this high to avoid upstream throttling.": "Recommended to keep this high to avoid upstream throttling.", @@ -3661,8 +3784,10 @@ "Refresh Cache": "Refresh Cache", "Refresh credential": "Refresh credential", "Refresh details": "Refresh details", + "Refresh every {{seconds}}s": "Refresh every {{seconds}}s", "Refresh failed": "Refresh failed", "Refresh interval (minutes)": "Refresh interval (minutes)", + "Refresh interval (seconds)": "Refresh interval (seconds)", "Refresh Stats": "Refresh Stats", "Refreshing...": "Refreshing...", "Refund": "Refund", @@ -3685,6 +3810,7 @@ "Relying Party Display Name": "Relying Party Display Name", "Relying Party ID": "Relying Party ID", "Remaining": "Remaining", + "Remaining pool": "Remaining pool", "Remaining quota": "Remaining quota", "Remaining Quota ({{currency}})": "Remaining Quota ({{currency}})", "Remaining quota units": "Remaining quota units", @@ -3704,6 +3830,7 @@ "Remove node filter": "Remove node filter", "Remove Passkey": "Remove Passkey", "Remove Passkey?": "Remove Passkey?", + "Remove prize": "Remove prize", "Remove rule group": "Remove rule group", "Remove string prefix": "Remove string prefix", "Remove string suffix": "Remove string suffix", @@ -3748,6 +3875,7 @@ "Request Header Field": "Request Header Field", "Request Header Override": "Request Header Override", "Request Header Overrides": "Request Header Overrides", + "Request health by billing group for the latest 100 consume/error logs. Green bars show latency; red bars are failures. Badge uses overall success rate (≥95% normal, ≥80% warning, below 80% abnormal).": "Request health by billing group for the latest 100 consume/error logs. Green bars show latency; red bars are failures. Badge uses overall success rate (≥95% normal, ≥80% warning, below 80% abnormal).", "Request ID": "Request ID", "Request Limits": "Request Limits", "Request Model": "Request Model", @@ -3999,6 +4127,7 @@ "Select a color": "Select a color", "Select a group": "Select a group", "Select a group type": "Select a group type", + "Select a model": "Select a model", "Select a model to edit pricing": "Select a model to edit pricing", "Select a preset...": "Select a preset...", "Select a product": "Select a product", @@ -4013,6 +4142,7 @@ "Select all (filtered)": "Select all (filtered)", "Select all models": "Select all models", "Select All Visible": "Select All Visible", + "Select an icon": "Select an icon", "Select an operation mode and enter the amount": "Select an operation mode and enter the amount", "Select announcement type": "Select announcement type", "Select at least one field to overwrite.": "Select at least one field to overwrite.", @@ -4046,6 +4176,7 @@ "Select models or add custom ones": "Select models or add custom ones", "Select models to process. Unselected \"add\" models will be ignored.": "Select models to process. Unselected \"add\" models will be ignored.", "Select models to run batch tests.": "Select models to run batch tests.", + "Select open mode": "Select open mode", "Select or enter color value": "Select or enter color value", "Select or enter method identifier": "Select or enter method identifier", "Select or enter model name": "Select or enter model name", @@ -4071,6 +4202,7 @@ "Select theme preset": "Select theme preset", "Select time granularity": "Select time granularity", "Select vendor": "Select vendor", + "Select visibility": "Select visibility", "Selectable groups": "Selectable groups", "selected": "selected", "Selected {{count}}": "Selected {{count}}", @@ -4127,6 +4259,7 @@ "Setting updated successfully": "Setting updated successfully", "Settings": "Settings", "Settings & Preferences": "Settings & Preferences", + "Settings saved": "Settings saved", "Settings updated successfully": "Settings updated successfully", "Setup guide": "Setup guide", "Setup guide complete": "Setup guide complete", @@ -4154,6 +4287,9 @@ "Showcase core capabilities with demo credentials and limited access.": "Showcase core capabilities with demo credentials and limited access.", "Showing": "Showing", "showing •": "showing •", + "Shown in the console sidebar. Maximum 100 characters.": "Shown in the console sidebar. Maximum 100 characters.", + "Shows a group-level request heartbeat chart under Extensions. Failed requests require ERROR_LOG_ENABLED.": "Shows a group-level request heartbeat chart under Extensions. Failed requests require ERROR_LOG_ENABLED.", + "Shows Lucky Slot under Extensions. Draws are decided by the backend once per user per day.": "Shows Lucky Slot under Extensions. Draws are decided by the backend once per user per day.", "Sidebar": "Sidebar", "Sidebar collapsed by default for new users": "Sidebar collapsed by default for new users", "Sidebar modules": "Sidebar modules", @@ -4220,6 +4356,9 @@ "Special usable group rules make extra token groups visible to, or hide default ones from, users of a specific user group.": "Special usable group rules make extra token groups visible to, or hide default ones from, users of a specific user group.", "Special visibility rules": "Special visibility rules", "Spend limited": "Spend limited", + "SPIN": "SPIN", + "SPINNING": "SPINNING", + "Spinning...": "Spinning...", "SQLite stores all data in a single file. Make sure that file is persisted when running in containers.": "SQLite stores all data in a single file. Make sure that file is persisted when running in containers.", "SSL/TLS": "SSL/TLS", "SSRF Protection": "SSRF Protection", @@ -4380,6 +4519,7 @@ "Tag updated successfully": "Tag updated successfully", "Tag:": "Tag:", "Tags": "Tags", + "Tap the button below to draw": "Tap the button below to draw", "Take photo": "Take photo", "Take screenshot": "Take screenshot", "Target Endpoint": "Target Endpoint", @@ -4434,6 +4574,7 @@ "Text or array of texts to embed": "Text or array of texts to embed", "Text Output": "Text Output", "Text to Video": "Text to Video", + "Thanks": "Thanks", "The admin configured three groups and one special ratio rule:": "The admin configured three groups and one special ratio rule:", "The admin wants vip users to pay even less when they use premium. That needs an override rule: in the override matrix, set the cell at row vip, column premium to 0.3.": "The admin wants vip users to pay even less when they use premium. That needs an override rule: in the override matrix, set the cell at row vip, column premium to 0.3.", "The administrator account is already initialized. You can keep your existing credentials and continue to the next step.": "The administrator account is already initialized. You can keep your existing credentials and continue to the next step.", @@ -4446,6 +4587,7 @@ "The bound Product powers wallet top-ups: when a user enters any amount, new-api runs the checkout against this single Pancake product and overrides the price per session — no need to pre-create $1 / $5 / $10 SKUs.": "The bound Product powers wallet top-ups: when a user enters any amount, new-api runs the checkout against this single Pancake product and overrides the price per session — no need to pre-create $1 / $5 / $10 SKUs.", "The bound Store is the parent container for every Pancake product new-api creates from this admin — both the wallet top-up product and any subscription-plan products. One store is enough; pin a different one only if you genuinely run separate Pancake catalogs.": "The bound Store is the parent container for every Pancake product new-api creates from this admin — both the wallet top-up product and any subscription-plan products. One store is enough; pin a different one only if you genuinely run separate Pancake catalogs.", "The deployment node that handled the requests": "The deployment node that handled the requests", + "The download will use the redemption name as the filename.": "The download will use the redemption name as the filename.", "The effective domain for Passkey registration. Must match the current domain or be its parent domain.": "The effective domain for Passkey registration. Must match the current domain or be its parent domain.", "The entered text does not match the required text.": "The entered text does not match the required text.", "The environment (test vs production) is decided by the key you paste here — use the Test key while integrating, then swap to the Production key when going live.": "The environment (test vs production) is decided by the key you paste here — use the Test key while integrating, then swap to the Production key when going live.", @@ -4458,6 +4600,7 @@ "The name displayed across the application": "The name displayed across the application", "The public URL of your server, used for OAuth callbacks, webhooks, and other external integrations": "The public URL of your server, used for OAuth callbacks, webhooks, and other external integrations", "The requested chat preset does not exist or has been removed.": "The requested chat preset does not exist or has been removed.", + "The requested page does not exist, is disabled, or has no URL configured.": "The requested page does not exist, is disabled, or has no URL configured.", "The reset request stays disabled until a credit is available.": "The reset request stays disabled until a credit is available.", "The setup wizard will use this database during initialization.": "The setup wizard will use this database during initialization.", "The site is not available at the moment.": "The site is not available at the moment.", @@ -4496,6 +4639,7 @@ "This channel type requires additional configuration": "This channel type requires additional configuration", "This confirmation unlocks payment, redemption code, subscription plan, and invitation reward features. Please read the statements carefully.": "This confirmation unlocks payment, redemption code, subscription plan, and invitation reward features. Please read the statements carefully.", "This controls model request rate limiting. Web/API route throttling is configured by environment variables and may still return 429.": "This controls model request rate limiting. Web/API route throttling is configured by environment variables and may still return 429.", + "This custom page will be removed from the list.": "This custom page will be removed from the list.", "This data may be unreliable, use with caution": "This data may be unreliable, use with caution", "This device does not support Passkey": "This device does not support Passkey", "This device does not support Passkey verification.": "This device does not support Passkey verification.", @@ -4515,6 +4659,7 @@ "This model is not available in any group, or no group pricing information is configured.": "This model is not available in any group, or no group pricing information is configured.", "This month": "This month", "This page has not been created yet.": "This page has not been created yet.", + "This page opens in a new browser tab because the target site cannot be embedded.": "This page opens in a new browser tab because the target site cannot be embedded.", "This plan does not allow balance redemption": "This plan does not allow balance redemption", "This project must be used in compliance with the": "This project must be used in compliance with the", "This removes {{count}} failed models from this channel. This action cannot be undone.": "This removes {{count}} failed models from this channel. This action cannot be undone.", @@ -4570,6 +4715,7 @@ "times": "times", "Timing": "Timing", "Tip": "Tip", + "Title": "Title", "to access this resource.": "to access this resource.", "To Anthropic Messages": "To Anthropic Messages", "to confirm": "to confirm", @@ -4711,6 +4857,10 @@ "TTL (seconds)": "TTL (seconds)", "Tune selection priority, testing, status handling, and request overrides.": "Tune selection priority, testing, status handling, and request overrides.", "Turnstile is enabled but site key is empty.": "Turnstile is enabled but site key is empty.", + "Turnstile site key is missing": "Turnstile site key is missing", + "Turnstile failed to load. Check that this domain is allowed in Cloudflare Turnstile hostnames.": "Turnstile failed to load. Check that this domain is allowed in Cloudflare Turnstile hostnames.", + "Turnstile script could not be loaded. Check network / ad blockers.": "Turnstile script could not be loaded. Check network / ad blockers.", + "Human verification is required before you can continue.": "Human verification is required before you can continue.", "Tutoring, learning aids, assessment": "Tutoring, learning aids, assessment", "Two-factor Authentication": "Two-factor Authentication", "Two-Factor Authentication": "Two-Factor Authentication", @@ -4728,7 +4878,9 @@ "UI granularity only — data is still aggregated hourly": "UI granularity only — data is still aggregated hourly", "Unable to estimate price for this deployment.": "Unable to estimate price for this deployment.", "Unable to generate chat link. Please contact your administrator.": "Unable to generate chat link. Please contact your administrator.", + "Unable to load availability": "Unable to load availability", "Unable to load groups": "Unable to load groups", + "Unable to load lottery": "Unable to load lottery", "Unable to load rankings": "Unable to load rankings", "Unable to load rankings data": "Unable to load rankings data", "Unable to open chat": "Unable to open chat", @@ -4747,6 +4899,7 @@ "Unexpected release payload": "Unexpected release payload", "Unified API Gateway for": "Unified API Gateway for", "Unique identifier for this group.": "Unique identifier for this group.", + "Unit is USD. Internally converted by QuotaPerUnit (default 500000 quota = $1). Doubled on Thursdays.": "Unit is USD. Internally converted by QuotaPerUnit (default 500000 quota = $1). Doubled on Thursdays.", "Unit price (local currency / USD)": "Unit price (local currency / USD)", "Unit price (USD)": "Unit price (USD)", "Unit price must be greater than 0": "Unit price must be greater than 0", @@ -4858,6 +5011,7 @@ "USD Exchange Rate": "USD Exchange Rate", "USD price per 1M input tokens.": "USD price per 1M input tokens.", "USD price per 1M tokens.": "USD price per 1M tokens.", + "Use “Open in new tab” for sites that block iframe embedding (for example Taobao / Xianyu short links).": "Use “Open in new tab” for sites that block iframe embedding (for example Taobao / Xianyu short links).", "Use +: to add a group, -: to remove a default selectable group, or no prefix to append a group.": "Use +: to add a group, -: to remove a default selectable group, or no prefix to append a group.", "Use a compatible browser or device with biometric authentication or a security key to register a Passkey.": "Use a compatible browser or device with biometric authentication or a security key to register a Passkey.", "Use a different stable value for each instance, then restart the service.": "Use a different stable value for each instance, then restart the service.", @@ -4936,6 +5090,7 @@ "Users call the model on the left. The platform forwards the request to the upstream model on the right.": "Users call the model on the left. The platform forwards the request to the upstream model on the right.", "Users in {{group}}": "Users in {{group}}", "Users must wait for a successful drawing before upscales or variations.": "Users must wait for a successful drawing before upscales or variations.", + "Users must have a quota upper limit (used + remaining) strictly greater than this USD value. Use this to require recharge (e.g. 5 if signup gift is $5 and min top-up is $10). Set 0 to disable.": "Users must have a quota upper limit (used + remaining) strictly greater than this USD value. Use this to require recharge (e.g. 5 if signup gift is $5 and min top-up is $10). Set 0 to disable.", "Users of vip, when billed as premium, pay ratio": "Users of vip, when billed as premium, pay ratio", "Users only see groups marked as user selectable. Non-selectable groups can still be assigned by administrators.": "Users only see groups marked as user selectable. Non-selectable groups can still be assigned by administrators.", "uses": "uses", @@ -5010,6 +5165,7 @@ "Violation Marker": "Violation Marker", "vip": "vip", "VIP users with premium access": "VIP users with premium access", + "Visibility": "Visibility", "Visible": "Visible", "Vision": "Vision", "Vision, image / video, document chat": "Vision, image / video, document chat", @@ -5139,6 +5295,7 @@ "You commit to using upstream APIs, accounts, keys, quotas, and service capabilities only within the scope of lawful authorization obtained from upstream service providers, model service providers, or relevant rights holders, and will not conduct unauthorized resale, trafficking, distribution, or other non-compliant commercialization.": "You commit to using upstream APIs, accounts, keys, quotas, and service capabilities only within the scope of lawful authorization obtained from upstream service providers, model service providers, or relevant rights holders, and will not conduct unauthorized resale, trafficking, distribution, or other non-compliant commercialization.", "You do not have permission to edit sensitive channel settings.": "You do not have permission to edit sensitive channel settings.", "You don't have necessary permission": "You don't have necessary permission", + "You got {{name}} ({{delta}})": "You got {{name}} ({{delta}})", "You have legally obtained authorization for the connected model APIs, accounts, keys, and quotas.": "You have legally obtained authorization for the connected model APIs, accounts, keys, and quotas.", "You have unsaved changes": "You have unsaved changes", "You have unsaved changes. Are you sure you want to leave?": "You have unsaved changes. Are you sure you want to leave?", @@ -5152,6 +5309,7 @@ "Your account cannot edit sensitive channel settings.": "Your account cannot edit sensitive channel settings.", "your AI integration?": "your AI integration?", "Your Azure OpenAI endpoint URL": "Your Azure OpenAI endpoint URL", + "Your balance": "Your balance", "Your Bot Name": "Your Bot Name", "Your Cloudflare Account ID": "Your Cloudflare Account ID", "Your Discord OAuth Client ID": "Your Discord OAuth Client ID", @@ -5159,6 +5317,7 @@ "Your GitHub OAuth Client ID": "Your GitHub OAuth Client ID", "Your GitHub OAuth Client Secret": "Your GitHub OAuth Client Secret", "Your new backup codes are ready": "Your new backup codes are ready", + "Your quota": "Your quota", "Your Referral Link": "Your Referral Link", "Your setup guide is collapsed so usage stays in focus.": "Your setup guide is collapsed so usage stays in focus.", "Your system access token for API authentication. Keep it secure and don't share it with others.": "Your system access token for API authentication. Keep it secure and don't share it with others.", diff --git a/web/default/src/i18n/locales/fr.json b/web/default/src/i18n/locales/fr.json index 996254c8fd62..27d443924bb7 100644 --- a/web/default/src/i18n/locales/fr.json +++ b/web/default/src/i18n/locales/fr.json @@ -38,6 +38,8 @@ "{{count}} channel(s) enabled": "{{count}} canal(canaux) activé(s)", "{{count}} channel(s) failed to disable": "{{count}} canal(canaux) n'ont pas pu être désactivé(s)", "{{count}} channel(s) failed to enable": "{{count}} canal(canaux) n'ont pas pu être activé(s)", + "{{count}} custom pages deleted. Click \"Save Settings\" to apply.": "{{count}} pages supprimées. Cliquez sur « Enregistrer » pour appliquer.", + "{{count}} custom pages will be removed from the list.": "{{count}} pages personnalisées seront retirées de la liste.", "{{count}} days ago": "il y a {{count}} jours", "{{count}} days remaining": "{{count}} days remaining", "{{count}} disabled channel(s) deleted": "{{count}} canal(canaux) désactivé(s) supprimé(s)", @@ -118,6 +120,8 @@ "80,443,8080": "80,443,8080", "A billing multiplier. Lower ratios mean lower API call costs.": "Un multiplicateur de facturation. Plus le ratio est faible, plus le coût des appels API est bas.", "A focused home for keys, balance, routing, and service health.": "Un accueil dédié aux clés, au solde, au routage et à l'état du service.", + "A recommended model is selected automatically. You can change it.": "Un modèle recommandé est présélectionné. Vous pouvez le modifier.", + "Abnormal": "Anormal", "About": "À propos", "About {{days}} days left": "Environ {{days}} jours restants", "Accept Unpriced Models": "Accepter les modèles non tarifés", @@ -174,6 +178,7 @@ "Add Condition": "Ajouter une condition", "Add credits": "Ajouter des crédits", "Add custom model \"{{value}}\"": "Ajouter le modèle personnalisé « {{value}} »", + "Add Custom Page": "Ajouter une page", "Add discount tier": "Ajouter un niveau de réduction", "Add each model or tag you want to include.": "Ajoutez chaque modèle ou étiquette que vous souhaitez inclure.", "Add FAQ": "Ajouter une FAQ", @@ -239,6 +244,7 @@ "Administer user accounts and roles.": "Gérer les comptes d'utilisateurs et les rôles.", "Administrator account": "Compte administrateur", "Administrator username": "Nom d'utilisateur administrateur", + "Admins only": "Admins seulement", "Advance next reset time": "Avancer la prochaine réinitialisation", "Advanced": "Avancé", "Advanced Configuration": "Configuration avancée", @@ -387,6 +393,7 @@ "API Key (Sandbox)": "Clé API (Sandbox)", "API Key *": "Clé API *", "API Key created successfully": "Clé API créée avec succès", + "API key created. Opening the selected tool...": "Clé API créée. Ouverture de l’outil sélectionné...", "API Key deleted successfully": "Clé API supprimée avec succès", "API Key disabled successfully": "Clé API désactivée avec succès", "API Key enabled successfully": "Clé API activée avec succès", @@ -520,7 +527,9 @@ "Automatically replaces upstream callback URLs with the server address.": "Remplace automatiquement les URL des callbacks en amont par l'adresse du serveur.", "Automatically selects the best available group with circuit breaker mechanism": "Sélectionne automatiquement le meilleur groupe disponible avec un mécanisme de disjoncteur de circuit", "Automatically sync model list when upstream changes are detected": "Synchroniser automatiquement la liste des modèles lorsque des changements en amont sont détectés", + "Availability": "Disponibilité", "Availability (last 24h)": "Disponibilité (dernières 24 h)", + "Availability Monitor": "Surveillance de disponibilité", "Available": "Disponible", "Available credits are ordered by soonest expiration.": "Les crédits disponibles sont triés par expiration la plus proche.", "Available disk space": "Espace disque disponible", @@ -535,6 +544,7 @@ "Average tokens per second sustained per group": "Tokens par seconde soutenus en moyenne par groupe", "Average TPM": "TPM moyen", "Average TTFT": "TTFT moyen", + "Avg latency": "Latence moyenne", "AWS": "AWS", "AWS Bedrock Claude Compat": "AWS Bedrock Claude Compat", "AWS Key Format": "Format de clé AWS", @@ -814,6 +824,8 @@ "Choose the default charts, range, and time granularity for model analytics.": "Choisissez les graphiques, la plage et la granularité temporelle par défaut pour l'analyse des modèles.", "Choose where to fetch upstream metadata.": "Choisissez où récupérer les métadonnées amont.", "Choose which charts are selected by default when opening model analytics.": "Choisissez les graphiques sélectionnés par défaut à l'ouverture de l'analyse des modèles.", + "Choose who can see the Availability Monitor entry in the Extensions sidebar.": "Choisissez qui voit la surveillance dans Extensions.", + "Choose who can see this page in the Extensions sidebar.": "Choisissez qui voit cette page dans Extensions.", "Clamped to": "Limité à", "Classic (Legacy Frontend)": "Classique (Ancien frontend)", "Claude": "Claude", @@ -949,6 +961,7 @@ "Configuration for Epay payment integration": "Configuration pour l'intégration de paiement Epay", "Configuration for Stripe payment integration": "Configuration pour l'intégration de paiement Stripe", "Configuration required": "Configuration requise", + "Configuration tool": "Outil de configuration", "Configure": "Configurer", "Configure a Creem product for user recharge options.": "Configurez un produit Creem pour les options de recharge utilisateur.", "Configure a custom ratio for when users use a specific token group.": "Configurer un ratio personnalisé lorsque les utilisateurs utilisent un groupe de jetons spécifique.", @@ -974,6 +987,8 @@ "Configure rate limiting rules for a specific user group.": "Configurer les règles de limitation de débit pour un groupe d'utilisateurs spécifique.", "Configure routes": "Configurer les routes", "Configure the ratio for this group.": "Configurer le ratio pour ce groupe.", + "Configure the sidebar title, icon, embed URL, status, and sort order.": "Configurez le titre, l’icône, l’URL intégrée, le statut et l’ordre.", + "Configure the sidebar title, icon, URL, open mode, status, and sort order.": "Configurez le titre, l’icône, l’URL, le mode d’ouverture, le statut et l’ordre.", "Configure upstream providers and routing.": "Configurer les fournisseurs en amont et le routage.", "Configure Waffo Pancake hosted checkout integration for USD-priced top-ups": "Configurer l'intégration du parcours de paiement hébergé Waffo Pancake pour les rechargements en USD", "Configure Waffo payment aggregation platform integration": "Configurer l'intégration de la plateforme d'agrégation de paiement Waffo", @@ -981,6 +996,7 @@ "Configure your account preferences and integrations": "Configurer les préférences et les intégrations de votre compte", "Configured as PayMethods JSON. The type value decides which payment flow is used: stripe for Stripe, waffo_pancake for Waffo Pancake, and other values are sent to Epay as the type parameter.": "Enregistré comme JSON PayMethods. La valeur type décide du flux de paiement utilisé : stripe pour Stripe, waffo_pancake pour Waffo Pancake, et les autres valeurs sont envoyées à Epay comme paramètre type.", "Configured routes and latency checks": "Routes configurées et contrôles de latence", + "Configuring...": "Configuration...", "Confirm": "Confirmer", "Confirm Action": "Confirmer l'action", "Confirm and enable": "Confirmer et activer", @@ -1015,6 +1031,7 @@ "Conflict": "Conflit", "Connect": "Connecter", "Connect through OpenAI, Claude, Gemini, and other compatible API routes": "Connectez-vous via OpenAI, Claude, Gemini et d'autres routes API compatibles", + "Connect tool": "Connecter un outil", "Connected to io.net service normally.": "Connexion au service io.net réussie.", "Connection closed": "Connexion fermée", "Connection error": "Erreur de connexion", @@ -1115,6 +1132,7 @@ "Cost = model price × that one ratio. Nothing else from the group settings enters the formula.": "Coût = prix du modèle × ce seul taux. Rien d’autre dans les réglages de groupes n’entre dans la formule.", "Cost in USD per request, regardless of tokens used.": "Coût en USD par requête, quel que soit le nombre de jetons utilisés.", "Cost Tracking": "Suivi des coûts", + "Could not load pricing data. Open the pricing page or refresh and try again.": "Impossible de charger les tarifs. Ouvrez la page des prix ou actualisez, puis réessayez.", "Count must be between {{min}} and {{max}}": "Le nombre doit être compris entre {{min}} et {{max}}", "Coze": "Coze", "CPU": "Processeur", @@ -1126,6 +1144,7 @@ "Create account": "Créer un compte", "Create an account": "Créer un compte", "Create an API key to unlock the real request": "Créez une clé API pour débloquer la requête réelle", + "Create and configure": "Créer et configurer", "Create and review invite or credit codes.": "Créer et examiner les codes d'invitation ou de crédit.", "Create API Key": "Créer une clé API", "Create cache": "Créer le cache", @@ -1214,6 +1233,12 @@ "Custom multipliers when specific user groups use specific token groups. Example: VIP users get 0.9x rate when using \"edit_this\" group tokens.": "Multiplicateurs personnalisés lorsque des groupes d'utilisateurs spécifiques utilisent des groupes de jetons spécifiques. Exemple : les utilisateurs VIP obtiennent un taux de 0,9x lorsqu'ils utilisent les jetons du groupe \"edit_this\".", "Custom OAuth": "OAuth personnalisé", "Custom OAuth Providers": "Fournisseurs OAuth personnalisés", + "Custom page added. Click \"Save Settings\" to apply.": "Page ajoutée. Cliquez sur « Enregistrer » pour appliquer.", + "Custom page deleted. Click \"Save Settings\" to apply.": "Page supprimée. Cliquez sur « Enregistrer » pour appliquer.", + "Custom page not found": "Page personnalisée introuvable", + "Custom page updated. Click \"Save Settings\" to apply.": "Page mise à jour. Cliquez sur « Enregistrer » pour appliquer.", + "Custom Pages": "Pages personnalisées", + "Custom pages saved successfully": "Pages personnalisées enregistrées", "Custom Seconds": "Secondes personnalisées", "Custom sidebar section": "Section de barre latérale personnalisée", "Custom Time Range": "Plage horaire personnalisée", @@ -1428,7 +1453,9 @@ "Do not wait one second between polling async tasks for this channel": "Ne pas attendre une seconde entre les interrogations des tâches asynchrones pour ce canal", "Do regex replacement in the target field": "Effectuer un remplacement par expression régulière dans le champ cible", "Do string replacement in the target field": "Effectuer un remplacement de chaîne dans le champ cible", + "Do you want to download the created redemption codes as a text file?": "Voulez-vous télécharger les codes de réduction créés sous forme de fichier texte ?", "Docs": "Documents", + "Documentation": "Documentation", "Documentation Link": "Lien de la documentation", "Documentation or external knowledge base.": "Documentation ou base de connaissances externe.", "does not exist or might have been removed.": "n'existe pas ou a peut-être été supprimé.", @@ -1521,6 +1548,7 @@ "Edit Channel": "Modifier le canal", "Edit channel routing": "Modifier le routage des canaux", "Edit chat preset": "Modifier le préréglage de chat", + "Edit Custom Page": "Modifier la page", "Edit discount tier": "Modifier le palier de remise", "Edit FAQ": "Modifier la FAQ", "Edit group": "Modifier le groupe", @@ -1557,6 +1585,7 @@ "Email Field": "Champ d'e-mail", "Email Verification": "Vérification d'e-mail", "Email, summarisation, knowledge work": "Email, résumé, travail intellectuel", + "Embed in console": "Intégrer dans la console", "Embeddings": "Embeddings", "Empty": "Vide", "Empty value will be saved as {}.": "Une valeur vide sera enregistrée comme {}.", @@ -1564,6 +1593,7 @@ "Enable {{parameter}}": "Activer {{parameter}}", "Enable 2FA": "Activer 2FA", "Enable All": "Tout activer", + "Enable availability monitor": "Activer la surveillance", "Enable check-in feature": "Activer la fonction de connexion", "Enable Data Dashboard": "Activer le tableau de bord des données", "Enable demo mode with limited functionality": "Activer le mode démo avec des fonctionnalités limitées", @@ -1606,6 +1636,7 @@ "Enabled": "Activé", "Enabled all channels with tag: {{tag}}": "Tous les canaux avec le tag {{tag}} ont été activés", "Enabled channels with tag {{tag}}": "Canaux avec l'étiquette {{tag}} activés", + "Enabled pages with a URL appear under the Extensions group in the console sidebar and open as embedded pages.": "Les pages activées avec une URL apparaissent dans le groupe Extensions de la barre latérale et s’ouvrent en page intégrée.", "Enabled Status": "Statut activé", "Enabling...": "Activation en cours...", "Encourages introducing new topics": "Encourage l'introduction de nouveaux sujets", @@ -1721,6 +1752,7 @@ "Estimated cost": "Coût estimé", "Estimated quota cost": "Coût de quota estimé", "Every group name in the pricing table can be used in two places: on a user (the user group, assigned by admins) and on a token (the token group, chosen when creating the token). Same name pool, two different jobs.": "Chaque nom de groupe du tableau tarifaire peut être utilisé à deux endroits : sur un utilisateur (groupe d’utilisateurs, attribué par les admins) et sur un jeton (groupe de jetons, choisi à la création du jeton). Même ensemble de noms, deux rôles différents.", + "Everyone": "Tout le monde", "Everything configured for this group, in one place.": "Toute la configuration de ce groupe, au même endroit.", "Exact": "Exact", "Exact Match": "Correspondance exacte", @@ -1765,6 +1797,7 @@ "Extend deployment": "Prolonger le déploiement", "Extend failed": "Échec de la prolongation", "Extended successfully": "Prolongé avec succès", + "Extensions": "Extensions", "External Device": "Appareil externe", "External link for users to purchase quota": "Lien externe permettant aux utilisateurs d'acheter du quota", "External operations": "Opérations externes", @@ -1843,6 +1876,7 @@ "Failed to initialize system": "Échec de l'initialisation du système", "Failed to load": "Échec du chargement", "Failed to load API keys": "Échec du chargement des Clés API", + "Failed to load availability": "Échec du chargement de la disponibilité", "Failed to load billing history": "Échec du chargement de l'historique de facturation", "Failed to load enabled models": "Échec du chargement des modèles activés", "Failed to load home page content": "Échec du chargement du contenu de la page d'accueil", @@ -1874,6 +1908,7 @@ "Failed to save": "Échec de la sauvegarde", "Failed to save announcements": "Échec de la sauvegarde des annonces", "Failed to save API info": "Échec de l'enregistrement des informations API", + "Failed to save custom pages": "Échec de l’enregistrement des pages personnalisées", "Failed to save FAQ": "Échec de la sauvegarde de la FAQ", "Failed to save Uptime Kuma groups": "Échec de la sauvegarde des groupes Uptime Kuma", "Failed to search API keys": "Échec de la recherche des Clés API", @@ -1965,8 +2000,8 @@ "Filter by MjProxy task ID": "Filtrer par ID de tâche MjProxy", "Filter by model name...": "Filtrer par nom du modèle...", "Filter by model...": "Filtrer par modèle...", - "Filter by name or ID...": "Filtrer par nom ou ID...", "Filter by name, ID, or key...": "Filtrer par nom, ID ou clé...", + "Filter by name, ID, or redemption code...": "Filtrer par nom, ID ou code de réduction...", "Filter by name...": "Filtrer par nom...", "Filter by node": "Filtrer par nœud", "Filter by price field": "Filtrer par champ de prix", @@ -2216,6 +2251,7 @@ "How It Works": "Comment ça marche", "How model mapping works": "Fonctionnement du mappage des modèles", "How much to charge for each US dollar of balance (Epay)": "Montant à facturer pour chaque dollar US de solde (Epay)", + "How often the Availability Monitor page automatically reloads data. Allowed range: 5–3600 seconds.": "Fréquence à laquelle la page de surveillance de disponibilité recharge automatiquement les données. Plage autorisée : 5–3600 secondes.", "How this model name should match requests": "Comment ce nom de modèle doit correspondre aux requêtes", "How to deliver the resulting image": "Comment délivrer l'image résultante", "How to get an io.net API Key": "Comment obtenir une clé API io.net", @@ -2257,6 +2293,7 @@ "If connecting to upstream One API or New API relay projects, use OpenAI type instead unless you know what you are doing": "Si vous vous connectez à des projets de relais One API ou New API en amont, utilisez le type OpenAI à la place sauf si vous savez ce que vous faites", "If default auto group is enabled, newly created tokens start with auto instead of an empty group.": "Si le groupe auto par défaut est activé, les nouveaux jetons commencent avec auto au lieu d’un groupe vide.", "If the affinity channel fails and retry succeeds on another channel, update affinity to the successful channel.": "Si le canal affinitaire échoue et qu'une nouvelle tentative réussit sur un autre canal, mettre à jour l'affinité vers le canal ayant réussi.", + "If the app did not open, install the tool and use this API key manually:": "Si l’application ne s’ouvre pas, installez l’outil et utilisez cette clé manuellement :", "If this keeps happening, please report it on GitHub Issues.": "Si cela continue, veuillez le signaler sur GitHub Issues.", "If you provide generative AI services to the public in mainland China, you will fulfill legal obligations including filing, security assessment, content safety, complaint handling, generated content labeling, log retention, and personal information protection.": "Si vous fournissez des services d’IA générative au public en Chine continentale, vous remplirez les obligations légales applicables, notamment le dépôt, l’évaluation de sécurité, la sécurité du contenu, le traitement des plaintes, l’étiquetage du contenu généré, la conservation des journaux et la protection des informations personnelles.", "Ignore": "Ignorer", @@ -2487,6 +2524,7 @@ "Load template...": "Charger le modèle...", "Loader": "Chargeur", "Loading": "Chargement", + "Loading available providers...": "Chargement des fournisseurs…", "Loading channel details": "Chargement des détails du canal", "Loading configuration": "Chargement de la configuration", "Loading content settings...": "Chargement des paramètres de contenu...", @@ -2527,6 +2565,7 @@ "Logs": "Journaux", "Look for a special ratio rule matching this user group and this billing group. If one exists, use its ratio. Otherwise use the billing group base ratio from the pricing table.": "Cherchez une règle de taux spécial correspondant à ce groupe d’utilisateurs et ce groupe de facturation. Si elle existe, utilisez son taux ; sinon le taux de base du groupe de facturation.", "Low balance": "Solde faible", + "Lower numbers appear first in the sidebar.": "Les nombres plus petits apparaissent en premier.", "Lowest median first-token latency": "Latence médiane de premier jeton la plus faible", "m": "m", "Maintenance": "Maintenance", @@ -2771,6 +2810,7 @@ "Multipliers for recharge pricing based on user groups.": "Multiplicateurs pour la tarification de recharge basés sur les groupes d'utilisateurs.", "Must be a valid URL": "Doit être une URL valide", "Must be at least 8 characters": "Doit contenir au moins 8 caractères", + "Must be http(s). Leave empty to keep the page hidden from the sidebar.": "Doit être en http(s). Laissez vide pour masquer la page.", "My Subscriptions": "Mes abonnements", "my-status": "mon-statut", "MySQL detected": "MySQL détecté", @@ -2841,6 +2881,7 @@ "No available Web chat links": "Aucun lien de chat Web disponible", "No backup": "Pas de sauvegarde", "No base input price": "Aucun prix d’entrée de base", + "No billing groups configured.": "Aucun groupe de facturation configuré.", "No billing records found": "Aucun enregistrement de facturation trouvé", "No capabilities reported for this model.": "Aucune capacité n'a été signalée pour ce modèle.", "No Change": "Aucun changement", @@ -2862,6 +2903,7 @@ "No containers": "Aucun conteneur", "No content to copy": "Aucun contenu à copier", "No custom OAuth providers configured yet.": "Aucun fournisseur OAuth personnalisé configuré pour le moment.", + "No custom pages yet. Click \"Add Custom Page\" to create one.": "Aucune page personnalisée. Cliquez sur « Ajouter une page » pour en créer une.", "No data": "Aucune donnée", "No Data": "Aucune donnée", "No data available": "Aucune donnée disponible", @@ -2880,6 +2922,7 @@ "No group": "Aucun groupe", "No group found.": "Aucun groupe trouvé.", "No group-based rate limits configured. Click \"Add group\" to get started.": "Aucune limite de taux basée sur les groupes configurée. Cliquez sur \"Ajouter un groupe\" pour commencer.", + "No groups available for this provider type": "Aucun groupe disponible pour ce type", "No groups match your search": "Aucun groupe ne correspond à votre recherche", "No groups yet. Add a group to get started.": "Aucun groupe pour le moment. Ajoutez un groupe pour commencer.", "No header overrides configured.": "Aucune surcharge d'en-têtes configurée.", @@ -2942,9 +2985,13 @@ "No processable upstream model updates for this channel": "Aucune mise à jour de modèle en amont traitable pour ce canal", "No products configured. Click \"Add product\" to get started.": "Aucun produit configuré. Cliquez sur \"Ajouter un produit\" pour commencer.", "No products match your search": "Aucun produit ne correspond à votre recherche", + "No provider types are available for your current groups.": "Aucun type n’est disponible pour vos groupes actuels.", + "No provider types are available. Types unlock when pricing models match Anthropic / OpenAI / Gemini / xAI for your groups — having channels alone is not enough.": "Aucun type disponible. Les types s’activent quand les modèles tarifés correspondent à Anthropic / OpenAI / Gemini / xAI pour vos groupes — avoir des canaux ne suffit pas.", + "No provider types are available. Types unlock when pricing models match Anthropic / OpenAI for your groups — having channels alone is not enough.": "Aucun type disponible. Les types s’activent quand les modèles tarifés correspondent à Anthropic / OpenAI pour vos groupes — avoir des canaux ne suffit pas.", "No providers available": "Aucun fournisseur disponible", "No Quota": "Aucun quota", "No ratio differences found": "Aucune différence de ratio trouvée", + "No recent requests for this group.": "Aucune requête récente pour ce groupe.", "No recent usage": "Aucune utilisation récente", "No records found. Try adjusting your filters.": "Aucun enregistrement trouvé. Essayez d'ajuster vos filtres.", "No redemption codes available. Create your first redemption code to get started.": "Aucun code d'échange disponible. Créez votre premier code d'échange pour commencer.", @@ -2996,6 +3043,7 @@ "Non-zero invitation rewards require compliance confirmation in Payment Gateway settings.": "Les récompenses d’invitation non nulles nécessitent une confirmation de conformité dans les paramètres de la passerelle de paiement.", "None": "Aucun", "noreply@example.com": "noreply@example.com", + "Normal": "Normal", "Normalized:": "Normalisé :", "Not available": "Non disponible", "Not backed up": "Non sauvegardé", @@ -3016,6 +3064,7 @@ "Notification Email": "E-mail de notification", "Notification Method": "Méthode de notification", "Notifications": "Notifications", + "Now": "Maintenant", "Now a user whose user group is vip creates tokens with different groups and makes one call with each:": "Un utilisateur du groupe vip crée maintenant des jetons avec différents groupes et effectue un appel avec chacun :", "Nucleus sampling probability mass": "Masse probabiliste de l'échantillonnage nucleus", "Number of codes to create": "Nombre de codes à créer", @@ -3076,6 +3125,7 @@ "Only available for admins. When enabled, you will receive a summary notification via your selected method when the scheduled model check detects upstream model changes or check failures.": "Uniquement disponible pour les administrateurs. Lorsque cette option est activée, vous recevrez une notification récapitulative via votre méthode sélectionnée lorsque la vérification planifiée des modèles détecte des changements de modèles en amont ou des échecs de vérification.", "Only configured combinations are overridden. All other calls keep the billing group base ratio.": "Seules les combinaisons configurées sont remplacées. Tous les autres appels gardent le taux de base du groupe de facturation.", "Only configured combinations are overridden. All other calls keep the token group base ratio.": "Seules les combinaisons configurées sont remplacées. Les autres appels conservent le ratio de base du groupe du jeton.", + "Only enabled pages with a URL are shown in the Extensions sidebar group.": "Seules les pages activées avec une URL apparaissent dans Extensions.", "Only enabled parameters are sent with the request.": "Seuls les paramètres activés sont envoyés avec la requête.", "Only enter the site origin, for example https://api.example.com. Do not include any path such as /api/user/epay/notify. Leave blank to use the server address.": "Saisissez uniquement l’origine du site, par exemple https://api.example.com. N’ajoutez aucun chemin comme /api/user/epay/notify. Laissez vide pour utiliser l’adresse du serveur.", "Only Mine": "Uniquement les miens", @@ -3094,6 +3144,7 @@ "Open in new tab": "Ouvrir dans un nouvel onglet", "Open in New Tab": "Ouvrir dans un nouvel onglet", "Open menu": "Ouvrir le menu", + "Open mode": "Mode d’ouverture", "Open release": "Ouvrir la version", "Open source": "Open source", "Open Source": "Open source", @@ -3253,6 +3304,7 @@ "Password reset: {{password}}": "Mot de passe réinitialisé : {{password}}", "Passwords do not match": "Les mots de passe ne correspondent pas", "Passwords don't match.": "Les mots de passe ne correspondent pas.", + "Past": "Passé", "Paste Connection Info": "Coller les infos de connexion", "Path": "Chemin", "Path not set": "Chemin non défini", @@ -3326,6 +3378,7 @@ "Personal use": "Usage personnel", "Personal use mode": "Mode usage personnel", "Pick a date": "Choisir une date", + "Pick a provider type, group, model, and client. We create an API key and open the tool for you.": "Choisissez le type, le groupe, le modèle et le client. Nous créons une clé API et ouvrons l’outil.", "Pick or create both a store and a product before saving.": "Choisissez ou créez à la fois une boutique et un produit avant d’enregistrer.", "Ping Interval (seconds)": "Intervalle de ping (secondes)", "Plan": "Plan", @@ -3522,6 +3575,7 @@ "Provider created successfully": "Fournisseur créé avec succès", "Provider deleted successfully": "Fournisseur supprimé avec succès", "Provider Name": "Nom du fournisseur", + "Provider type": "Type de fournisseur", "Provider type (OpenAI, Anthropic, etc.)": "Type de fournisseur (OpenAI, Anthropic, etc.)", "Provider updated successfully": "Fournisseur mis à jour avec succès", "Provider-specific endpoint, account, and compatibility settings.": "Paramètres de point d’accès, de compte et de compatibilité propres au fournisseur.", @@ -3613,6 +3667,7 @@ "Receive Upstream Model Update Notifications": "Recevoir les notifications de mise à jour des modèles en amont", "Received": "Reçu", "Received amount": "Montant reçu", + "Recent {{count}} records": "{{count}} enregistrements récents", "Recent maintenance tasks running across instances and their execution status.": "Tâches de maintenance récentes exécutées sur les instances et leur état d'exécution.", "Recently completed or failed system task runs.": "Exécutions de tâches système récemment terminées ou échouées.", "Recently launched models": "Modèles récemment lancés", @@ -3661,8 +3716,10 @@ "Refresh Cache": "Actualiser le cache", "Refresh credential": "Actualiser l'identifiant", "Refresh details": "Actualiser les détails", + "Refresh every {{seconds}}s": "Actualisation toutes les {{seconds}}s", "Refresh failed": "Échec de l'actualisation", "Refresh interval (minutes)": "Intervalle d'actualisation (minutes)", + "Refresh interval (seconds)": "Intervalle d’actualisation (secondes)", "Refresh Stats": "Actualiser les statistiques", "Refreshing...": "Actualisation...", "Refund": "Remboursement", @@ -3748,6 +3805,7 @@ "Request Header Field": "Champ d'en-tête de requête", "Request Header Override": "Remplacement des en-têtes de requête", "Request Header Overrides": "Remplacements d'en-têtes de requête", + "Request health by billing group for the latest 100 consume/error logs. Green bars show latency; red bars are failures. Badge uses overall success rate (≥95% normal, ≥80% warning, below 80% abnormal).": "Santé des requêtes par groupe (100 derniers logs). Barres vertes = latence, rouges = échecs. Badge selon le taux de succès.", "Request ID": "ID de requête", "Request Limits": "Limites de requêtes", "Request Model": "Modèle demandé", @@ -3999,6 +4057,7 @@ "Select a color": "Sélectionner une couleur", "Select a group": "Sélectionner un groupe", "Select a group type": "Sélectionner un type de groupe", + "Select a model": "Sélectionner un modèle", "Select a model to edit pricing": "Sélectionnez un modèle pour modifier sa tarification", "Select a preset...": "Sélectionner un préréglage...", "Select a product": "Sélectionner un produit", @@ -4013,6 +4072,7 @@ "Select all (filtered)": "Tout sélectionner (filtré)", "Select all models": "Sélectionner tous les modèles", "Select All Visible": "Sélectionner tout ce qui est visible", + "Select an icon": "Sélectionner une icône", "Select an operation mode and enter the amount": "Sélectionnez un mode d'opération et entrez le montant", "Select announcement type": "Sélectionner le type d'annonce", "Select at least one field to overwrite.": "Sélectionnez au moins un champ à écraser.", @@ -4046,6 +4106,7 @@ "Select models or add custom ones": "Sélectionner des modèles ou en ajouter des personnalisés", "Select models to process. Unselected \"add\" models will be ignored.": "Sélectionnez les modèles à traiter. Les modèles « ajout » non sélectionnés seront ignorés.", "Select models to run batch tests.": "Sélectionner les modèles pour exécuter les tests par lots.", + "Select open mode": "Choisir le mode d’ouverture", "Select or enter color value": "Sélectionner ou saisir une valeur de couleur", "Select or enter method identifier": "Sélectionner ou saisir l’identifiant du mode", "Select or enter model name": "Sélectionner ou saisir le nom du modèle", @@ -4071,6 +4132,7 @@ "Select theme preset": "Sélectionner un préréglage de thème", "Select time granularity": "Sélectionner la granularité temporelle", "Select vendor": "Sélectionner le fournisseur", + "Select visibility": "Choisir la visibilité", "Selectable groups": "Groupes sélectionnables", "selected": "sélectionné", "Selected {{count}}": "{{count}} sélectionné(s)", @@ -4154,6 +4216,8 @@ "Showcase core capabilities with demo credentials and limited access.": "Présenter les fonctionnalités principales avec des identifiants de démonstration et un accès limité.", "Showing": "Affichage de", "showing •": "affichage •", + "Shown in the console sidebar. Maximum 100 characters.": "Affiché dans la barre latérale. 100 caractères maximum.", + "Shows a group-level request heartbeat chart under Extensions. Failed requests require ERROR_LOG_ENABLED.": "Affiche un graphique de requêtes par groupe sous Extensions. Les échecs nécessitent ERROR_LOG_ENABLED.", "Sidebar": "Barre latérale", "Sidebar collapsed by default for new users": "Barre latérale masquée par défaut pour les nouveaux utilisateurs", "Sidebar modules": "Modules de la barre latérale", @@ -4446,6 +4510,7 @@ "The bound Product powers wallet top-ups: when a user enters any amount, new-api runs the checkout against this single Pancake product and overrides the price per session — no need to pre-create $1 / $5 / $10 SKUs.": "Le produit associé alimente les recharges de portefeuille : lorsqu’un utilisateur saisit un montant, new-api lance le paiement sur ce produit Pancake unique et remplace le prix pour la session, sans devoir précréer des SKU de 1 $, 5 $ ou 10 $.", "The bound Store is the parent container for every Pancake product new-api creates from this admin — both the wallet top-up product and any subscription-plan products. One store is enough; pin a different one only if you genuinely run separate Pancake catalogs.": "La boutique associée est le conteneur parent de tous les produits Pancake que new-api crée depuis cette administration, y compris le produit de recharge de portefeuille et les produits de forfaits d’abonnement. Une seule boutique suffit ; choisissez-en une autre uniquement si vous gérez réellement des catalogues Pancake séparés.", "The deployment node that handled the requests": "Le nœud de déploiement ayant traité les requêtes", + "The download will use the redemption name as the filename.": "Le fichier sera enregistré en utilisant le nom du code de réduction comme nom de fichier.", "The effective domain for Passkey registration. Must match the current domain or be its parent domain.": "Le domaine effectif pour l'enregistrement de la clé d'accès. Doit correspondre au domaine actuel ou être son domaine parent.", "The entered text does not match the required text.": "Le texte saisi ne correspond pas au texte requis.", "The environment (test vs production) is decided by the key you paste here — use the Test key while integrating, then swap to the Production key when going live.": "L’environnement (test ou production) est déterminé par la clé collée ici : utilisez la clé de test pendant l’intégration, puis remplacez-la par la clé de production lors de la mise en ligne.", @@ -4458,6 +4523,7 @@ "The name displayed across the application": "Le nom affiché dans l'application", "The public URL of your server, used for OAuth callbacks, webhooks, and other external integrations": "L'URL publique de votre serveur, utilisée pour les rappels OAuth, les webhooks et autres intégrations externes", "The requested chat preset does not exist or has been removed.": "Le préréglage de discussion demandé n'existe pas ou a été supprimé.", + "The requested page does not exist, is disabled, or has no URL configured.": "La page demandée n’existe pas, est désactivée ou n’a pas d’URL.", "The reset request stays disabled until a credit is available.": "La demande de réinitialisation reste désactivée tant qu’aucun crédit n’est disponible.", "The setup wizard will use this database during initialization.": "L'assistant de configuration utilisera cette base de données lors de l'initialisation.", "The site is not available at the moment.": "Le site n'est pas disponible pour le moment.", @@ -4496,6 +4562,7 @@ "This channel type requires additional configuration": "Ce type de canal nécessite une configuration supplémentaire", "This confirmation unlocks payment, redemption code, subscription plan, and invitation reward features. Please read the statements carefully.": "Cette confirmation déverrouille les fonctionnalités de paiement, de codes de兑换, de forfaits d’abonnement et de récompenses d’invitation. Veuillez lire attentivement les déclarations.", "This controls model request rate limiting. Web/API route throttling is configured by environment variables and may still return 429.": "Ce réglage contrôle la limitation des requêtes de modèles. La limitation des routes Web/API se configure via les variables d'environnement et peut encore renvoyer 429.", + "This custom page will be removed from the list.": "Cette page personnalisée sera retirée de la liste.", "This data may be unreliable, use with caution": "Ces données peuvent être peu fiables, utilisez-les avec prudence", "This device does not support Passkey": "Cet appareil ne prend pas en charge Passkey", "This device does not support Passkey verification.": "Cet appareil ne prend pas en charge la vérification par clé d'accès.", @@ -4515,6 +4582,7 @@ "This model is not available in any group, or no group pricing information is configured.": "Ce modèle n'est disponible dans aucun groupe, ou aucune information de tarification de groupe n'est configurée.", "This month": "Ce mois-ci", "This page has not been created yet.": "Cette page n'a pas encore été créée.", + "This page opens in a new browser tab because the target site cannot be embedded.": "Cette page s’ouvre dans un nouvel onglet car le site cible ne peut pas être intégré.", "This plan does not allow balance redemption": "Ce forfait ne permet pas le paiement avec le solde", "This project must be used in compliance with the": "Ce projet doit être utilisé conformément aux", "This removes {{count}} failed models from this channel. This action cannot be undone.": "Cela supprime {{count}} modèles en échec de ce canal. Cette action est irréversible.", @@ -4570,6 +4638,7 @@ "times": "Fois", "Timing": "Durée", "Tip": "Astuce", + "Title": "Titre", "to access this resource.": "pour accéder à cette ressource.", "To Anthropic Messages": "Vers Anthropic Messages", "to confirm": "pour confirmer", @@ -4728,6 +4797,7 @@ "UI granularity only — data is still aggregated hourly": "Granularité de l'interface uniquement — les données sont toujours agrégées par heure", "Unable to estimate price for this deployment.": "Impossible d'estimer le prix pour ce déploiement.", "Unable to generate chat link. Please contact your administrator.": "Impossible de générer le lien de discussion. Veuillez contacter votre administrateur.", + "Unable to load availability": "Impossible de charger la disponibilité", "Unable to load groups": "Impossible de charger les groupes", "Unable to load rankings": "Impossible de charger les classements", "Unable to load rankings data": "Impossible de charger les données des classements", @@ -4858,6 +4928,7 @@ "USD Exchange Rate": "Taux de change USD", "USD price per 1M input tokens.": "Prix en USD par million de tokens d’entrée.", "USD price per 1M tokens.": "Prix en USD par million de tokens.", + "Use “Open in new tab” for sites that block iframe embedding (for example Taobao / Xianyu short links).": "Utilisez « Ouvrir dans un nouvel onglet » pour les sites qui bloquent l’iframe.", "Use +: to add a group, -: to remove a default selectable group, or no prefix to append a group.": "Utilisez +: pour ajouter un groupe, -: pour supprimer un groupe sélectionnable par défaut, ou aucun préfixe pour annexer un groupe.", "Use a compatible browser or device with biometric authentication or a security key to register a Passkey.": "Utilisez un navigateur ou un appareil compatible avec l'authentification biométrique ou une clé de sécurité pour enregistrer une clé d'accès (Passkey).", "Use a different stable value for each instance, then restart the service.": "Utilisez une valeur stable différente pour chaque instance, puis redémarrez le service.", @@ -5010,6 +5081,7 @@ "Violation Marker": "Marqueur de violation", "vip": "vip", "VIP users with premium access": "Utilisateurs VIP avec accès premium", + "Visibility": "Visibilité", "Visible": "Visible", "Vision": "Vision", "Vision, image / video, document chat": "Vision, image / vidéo, conversation sur document", diff --git a/web/default/src/i18n/locales/ja.json b/web/default/src/i18n/locales/ja.json index 0449ac251d83..e69e9812677c 100644 --- a/web/default/src/i18n/locales/ja.json +++ b/web/default/src/i18n/locales/ja.json @@ -38,6 +38,8 @@ "{{count}} channel(s) enabled": "{{count}} 個のチャネルを有効にしました", "{{count}} channel(s) failed to disable": "{{count}} 個のチャネルの無効化に失敗しました", "{{count}} channel(s) failed to enable": "{{count}} 個のチャネルの有効化に失敗しました", + "{{count}} custom pages deleted. Click \"Save Settings\" to apply.": "{{count}} 件のカスタムページを削除しました。「設定を保存」をクリックして反映してください。", + "{{count}} custom pages will be removed from the list.": "{{count}} 件のカスタムページが一覧から削除されます。", "{{count}} days ago": "{{count}} 日前", "{{count}} days remaining": "残り {{count}} 日", "{{count}} disabled channel(s) deleted": "{{count}} 個の無効チャネルを削除しました", @@ -118,6 +120,8 @@ "80,443,8080": "80,443,8080", "A billing multiplier. Lower ratios mean lower API call costs.": "課金倍率です。倍率が低いほど API 呼び出しコストは低くなります。", "A focused home for keys, balance, routing, and service health.": "キー、残高、ルーティング、サービス状態を集約したホームです。", + "A recommended model is selected automatically. You can change it.": "推奨モデルが自動選択されています。変更もできます。", + "Abnormal": "異常", "About": "このサービスについて", "About {{days}} days left": "約 {{days}} 日分", "Accept Unpriced Models": "価格設定されていないモデルを許可", @@ -174,6 +178,7 @@ "Add Condition": "条件を追加", "Add credits": "クレジットを追加", "Add custom model \"{{value}}\"": "カスタムモデル「{{value}}」を追加", + "Add Custom Page": "カスタムページを追加", "Add discount tier": "割引ティアを追加", "Add each model or tag you want to include.": "含めたい各モデルまたはタグを追加。", "Add FAQ": "FAQ追加", @@ -239,6 +244,7 @@ "Administer user accounts and roles.": "ユーザーアカウントとロールを管理します。", "Administrator account": "管理者アカウント", "Administrator username": "管理者ユーザー名", + "Admins only": "管理者のみ", "Advance next reset time": "次回リセット時刻を進める", "Advanced": "高度な設定", "Advanced Configuration": "詳細設定", @@ -387,6 +393,7 @@ "API Key (Sandbox)": "APIキー(サンドボックス)", "API Key *": "APIキー *", "API Key created successfully": "APIキーが正常に作成されました", + "API key created. Opening the selected tool...": "APIキーを作成しました。選択したツールを開いています...", "API Key deleted successfully": "APIキーが正常に削除されました", "API Key disabled successfully": "APIキーが正常に無効化されました", "API Key enabled successfully": "APIキーが正常に有効化されました", @@ -520,7 +527,9 @@ "Automatically replaces upstream callback URLs with the server address.": "アップストリームコールバック URL をサーバーアドレスに自動的に置き換えます。", "Automatically selects the best available group with circuit breaker mechanism": "回路ブレーカーメカニズム付きで最適な利用可能なグループを自動的に選択", "Automatically sync model list when upstream changes are detected": "アップストリームの変更が検出されたときにモデルリストを自動的に同期", + "Availability": "可用性", "Availability (last 24h)": "可用性(過去 24 時間)", + "Availability Monitor": "可用性モニタ", "Available": "空き", "Available credits are ordered by soonest expiration.": "利用可能なクレジットは有効期限の近い順に表示されます。", "Available disk space": "利用可能なディスク容量", @@ -535,6 +544,7 @@ "Average tokens per second sustained per group": "グループごとに持続する平均スループット (tokens/秒)", "Average TPM": "平均TPM", "Average TTFT": "平均 TTFT", + "Avg latency": "平均遅延", "AWS": "AWS", "AWS Bedrock Claude Compat": "AWS Bedrock Claude 互換テンプレート", "AWS Key Format": "AWSキーフォーマット", @@ -814,6 +824,8 @@ "Choose the default charts, range, and time granularity for model analytics.": "モデル分析のデフォルトチャート、範囲、時間粒度を選択します。", "Choose where to fetch upstream metadata.": "アップストリームのメタデータをどこからフェッチするかを選択してください。", "Choose which charts are selected by default when opening model analytics.": "モデル分析を開いたときにデフォルトで選択されるチャートを選択します。", + "Choose who can see the Availability Monitor entry in the Extensions sidebar.": "サイドバーの拡張で可用性モニタを見られる人を選択します。", + "Choose who can see this page in the Extensions sidebar.": "サイドバーの拡張でこのページを見られる人を選択します。", "Clamped to": "制限後の値", "Classic (Legacy Frontend)": "クラシック(旧フロントエンド)", "Claude": "Claude", @@ -949,6 +961,7 @@ "Configuration for Epay payment integration": "Epay決済連携のための設定", "Configuration for Stripe payment integration": "Stripe決済連携のための設定", "Configuration required": "設定が必要です", + "Configuration tool": "設定ツール", "Configure": "設定", "Configure a Creem product for user recharge options.": "ユーザー チャージオプション用の Creem 製品を設定。", "Configure a custom ratio for when users use a specific token group.": "ユーザーが特定のトークングループを使用する際のカスタム倍率を設定します。", @@ -974,6 +987,8 @@ "Configure rate limiting rules for a specific user group.": "特定のユーザーグループのレート制限ルールを設定します。", "Configure routes": "ルートを設定", "Configure the ratio for this group.": "このグループの比率を設定します。", + "Configure the sidebar title, icon, embed URL, status, and sort order.": "サイドバーのタイトル、アイコン、埋め込み URL、状態、並び順を設定します。", + "Configure the sidebar title, icon, URL, open mode, status, and sort order.": "サイドバーのタイトル、アイコン、URL、開く方法、状態、並び順を設定します。", "Configure upstream providers and routing.": "アップストリームプロバイダーとルーティングを設定。", "Configure Waffo Pancake hosted checkout integration for USD-priced top-ups": "USD 建てのチャージ用に Waffo Pancake のホスト型チェックアウト連携を設定", "Configure Waffo payment aggregation platform integration": "Waffo決済アグリゲーションプラットフォームの連携を設定", @@ -981,6 +996,7 @@ "Configure your account preferences and integrations": "アカウントの設定と統合を設定します。", "Configured as PayMethods JSON. The type value decides which payment flow is used: stripe for Stripe, waffo_pancake for Waffo Pancake, and other values are sent to Epay as the type parameter.": "PayMethods JSON として保存されます。type 値で使用する決済フローを決定します。stripe は Stripe、waffo_pancake は Waffo Pancake、それ以外の値は Epay の type パラメーターとして送信されます。", "Configured routes and latency checks": "設定済みルートとレイテンシ確認", + "Configuring...": "設定中...", "Confirm": "確認", "Confirm Action": "アクションの確認", "Confirm and enable": "確認して有効化", @@ -1015,6 +1031,7 @@ "Conflict": "競合", "Connect": "接続", "Connect through OpenAI, Claude, Gemini, and other compatible API routes": "OpenAI、Claude、Gemini、その他の互換APIルートから接続", + "Connect tool": "ツール接続", "Connected to io.net service normally.": "io.net サービスに正常に接続しました。", "Connection closed": "接続が閉じられました", "Connection error": "接続エラー", @@ -1115,6 +1132,7 @@ "Cost = model price × that one ratio. Nothing else from the group settings enters the formula.": "費用 = モデル価格 × この1つの倍率。グループ設定の他の項目は計算式に入りません。", "Cost in USD per request, regardless of tokens used.": "使用されたトークンに関係なく、リクエストあたりのUSDでのコスト。", "Cost Tracking": "コスト追跡", + "Could not load pricing data. Open the pricing page or refresh and try again.": "料金データを読み込めませんでした。料金ページを開くか、更新してから再試行してください。", "Count must be between {{min}} and {{max}}": "カウントは{{min}}から{{max}}の間である必要があります", "Coze": "Coze", "CPU": "CPU", @@ -1126,6 +1144,7 @@ "Create account": "アカウントを作成", "Create an account": "アカウントを作成", "Create an API key to unlock the real request": "実際のリクエストを使うには API キーを作成してください", + "Create and configure": "作成して設定", "Create and review invite or credit codes.": "招待コードまたはクレジットコードを作成および確認。", "Create API Key": "APIキーを作成", "Create cache": "キャッシュを作成", @@ -1214,6 +1233,12 @@ "Custom multipliers when specific user groups use specific token groups. Example: VIP users get 0.9x rate when using \"edit_this\" group tokens.": "特定のユーザーグループが特定のトークングループを使用する場合のカスタム乗数。例: VIPユーザーが「edit_this」グループトークンを使用する場合、0.9倍のレートが適用されます。", "Custom OAuth": "カスタム OAuth", "Custom OAuth Providers": "カスタムOAuthプロバイダー", + "Custom page added. Click \"Save Settings\" to apply.": "カスタムページを追加しました。「設定を保存」をクリックして反映してください。", + "Custom page deleted. Click \"Save Settings\" to apply.": "カスタムページを削除しました。「設定を保存」をクリックして反映してください。", + "Custom page not found": "カスタムページが見つかりません", + "Custom page updated. Click \"Save Settings\" to apply.": "カスタムページを更新しました。「設定を保存」をクリックして反映してください。", + "Custom Pages": "カスタムページ", + "Custom pages saved successfully": "カスタムページを保存しました", "Custom Seconds": "カスタム秒数", "Custom sidebar section": "カスタムサイドバーセクション", "Custom Time Range": "カスタム時間範囲", @@ -1428,7 +1453,9 @@ "Do not wait one second between polling async tasks for this channel": "このチャネルの非同期タスクをポーリングする間に1秒待機しない", "Do regex replacement in the target field": "ターゲットフィールドで正規表現置換", "Do string replacement in the target field": "ターゲットフィールドで文字列置換", + "Do you want to download the created redemption codes as a text file?": "作成した引き換えコードをテキストファイルとしてダウンロードしますか?", "Docs": "ドキュメント", + "Documentation": "ドキュメント", "Documentation Link": "ドキュメントリンク", "Documentation or external knowledge base.": "ドキュメントまたは外部知識ベース。", "does not exist or might have been removed.": "存在しないか、削除された可能性があります。", @@ -1521,6 +1548,7 @@ "Edit Channel": "チャネルを編集", "Edit channel routing": "チャネルルーティングを編集", "Edit chat preset": "チャットプリセットを編集", + "Edit Custom Page": "カスタムページを編集", "Edit discount tier": "割引ティアを編集", "Edit FAQ": "FAQ を編集", "Edit group": "グループを編集", @@ -1557,6 +1585,7 @@ "Email Field": "メールフィールド", "Email Verification": "メール認証", "Email, summarisation, knowledge work": "メール・要約・ナレッジワーク", + "Embed in console": "コンソール内に埋め込む", "Embeddings": "埋め込み", "Empty": "空", "Empty value will be saved as {}.": "空の値は {} として保存されます。", @@ -1564,6 +1593,7 @@ "Enable {{parameter}}": "{{parameter}}を有効化", "Enable 2FA": "2FA を有効にする", "Enable All": "すべて有効にする", + "Enable availability monitor": "可用性モニタを有効化", "Enable check-in feature": "チェックイン機能を有効にする", "Enable Data Dashboard": "データダッシュボードを有効にする", "Enable demo mode with limited functionality": "機能が制限されたデモモードを有効にする", @@ -1606,6 +1636,7 @@ "Enabled": "有効", "Enabled all channels with tag: {{tag}}": "タグ「{{tag}}」の全チャネルを有効にしました", "Enabled channels with tag {{tag}}": "タグ {{tag}} のチャネルを有効化しました", + "Enabled pages with a URL appear under the Extensions group in the console sidebar and open as embedded pages.": "有効かつ URL のあるページはサイドバーの「拡張」グループに表示され、埋め込みページとして開きます。", "Enabled Status": "有効ステータス", "Enabling...": "有効化中...", "Encourages introducing new topics": "新しい話題への展開を促進します", @@ -1721,6 +1752,7 @@ "Estimated cost": "推定コスト", "Estimated quota cost": "想定クォートコスト", "Every group name in the pricing table can be used in two places: on a user (the user group, assigned by admins) and on a token (the token group, chosen when creating the token). Same name pool, two different jobs.": "料金表の各グループ名は2つの場所で使えます。ユーザー側(ユーザーグループ、管理者が割り当て)とトークン側(トークングループ、トークン作成時に選択)です。同じ名前プールで、役割は2つです。", + "Everyone": "全員", "Everything configured for this group, in one place.": "このグループのすべての設定を一か所で確認できます。", "Exact": "完全一致", "Exact Match": "完全一致", @@ -1765,6 +1797,7 @@ "Extend deployment": "デプロイメントを延長", "Extend failed": "延長に失敗しました", "Extended successfully": "正常に延長されました", + "Extensions": "拡張", "External Device": "外部デバイス", "External link for users to purchase quota": "ユーザーがクォータを購入するための外部リンク", "External operations": "外部運用", @@ -1843,6 +1876,7 @@ "Failed to initialize system": "システムの初期化に失敗しました", "Failed to load": "読み込みに失敗しました", "Failed to load API keys": "APIキーの読み込みに失敗しました", + "Failed to load availability": "可用性データの読み込みに失敗しました", "Failed to load billing history": "請求履歴の読み込みに失敗しました", "Failed to load enabled models": "有効なモデルの取得に失敗しました", "Failed to load home page content": "ホームページの内容の読み込みに失敗しました", @@ -1874,6 +1908,7 @@ "Failed to save": "保存に失敗", "Failed to save announcements": "お知らせの保存に失敗しました", "Failed to save API info": "API情報の保存に失敗しました", + "Failed to save custom pages": "カスタムページの保存に失敗しました", "Failed to save FAQ": "FAQの保存に失敗しました", "Failed to save Uptime Kuma groups": "Uptime Kumaグループの保存に失敗しました", "Failed to search API keys": "APIキーの検索に失敗しました", @@ -1965,8 +2000,8 @@ "Filter by MjProxy task ID": "MjProxyタスクIDでフィルター", "Filter by model name...": "モデル名でフィルター...", "Filter by model...": "モデルでフィルタリング...", - "Filter by name or ID...": "名前またはIDでフィルター...", "Filter by name, ID, or key...": "名前、ID、またはキーでフィルター...", + "Filter by name, ID, or redemption code...": "名前、ID、または引き換えコードでフィルター...", "Filter by name...": "名前でフィルター...", "Filter by node": "ノードでフィルター", "Filter by price field": "価格フィールドでフィルター", @@ -2216,6 +2251,7 @@ "How It Works": "仕組み", "How model mapping works": "モデルマッピングの仕組み", "How much to charge for each US dollar of balance (Epay)": "残高の 1 米ドルあたりに請求する金額 (Epay)", + "How often the Availability Monitor page automatically reloads data. Allowed range: 5–3600 seconds.": "可用性モニターページがデータを自動で再読み込みする間隔です。許可範囲:5~3600 秒。", "How this model name should match requests": "このモデル名がリクエストとどのように一致すべきか", "How to deliver the resulting image": "画像結果の返却方法", "How to get an io.net API Key": "io.net API キーの取得方法", @@ -2257,6 +2293,7 @@ "If connecting to upstream One API or New API relay projects, use OpenAI type instead unless you know what you are doing": "上流の One API または New API リレープロジェクトに接続する場合、知っている場合を除き OpenAI タイプを使用してください", "If default auto group is enabled, newly created tokens start with auto instead of an empty group.": "デフォルト auto グループを有効にすると、新規トークンは空グループではなく auto で開始します。", "If the affinity channel fails and retry succeeds on another channel, update affinity to the successful channel.": "アフィニティチャネルが失敗し、別のチャネルでリトライが成功した場合、アフィニティを成功したチャネルに更新します。", + "If the app did not open, install the tool and use this API key manually:": "アプリが開かない場合は、ツールをインストールし、次のキーで手動設定してください:", "If this keeps happening, please report it on GitHub Issues.": "この問題が続く場合は、GitHub Issues で報告してください。", "If you provide generative AI services to the public in mainland China, you will fulfill legal obligations including filing, security assessment, content safety, complaint handling, generated content labeling, log retention, and personal information protection.": "中国本土で一般向けに生成 AI サービスを提供する場合、届出、セキュリティ評価、コンテンツ安全、苦情対応、生成コンテンツのラベル表示、ログ保存、個人情報保護などの法的義務を履行します。", "Ignore": "無視", @@ -2487,6 +2524,7 @@ "Load template...": "テンプレートをロード...", "Loader": "ローダー", "Loading": "読み込み中", + "Loading available providers...": "利用可能なタイプを読み込み中…", "Loading channel details": "チャネル詳細を読み込み中", "Loading configuration": "設定を読み込んでいます", "Loading content settings...": "コンテンツ設定をロード中...", @@ -2527,6 +2565,7 @@ "Logs": "ログ", "Look for a special ratio rule matching this user group and this billing group. If one exists, use its ratio. Otherwise use the billing group base ratio from the pricing table.": "このユーザーグループと課金グループに一致する特別倍率ルールを探します。あればその倍率を、なければ料金表の課金グループの基本倍率を使います。", "Low balance": "残高不足", + "Lower numbers appear first in the sidebar.": "数値が小さいほどサイドバーで先に表示されます。", "Lowest median first-token latency": "最初のトークンまでの中央値レイテンシの最小値", "m": "m", "Maintenance": "メンテナンス", @@ -2771,6 +2810,7 @@ "Multipliers for recharge pricing based on user groups.": "ユーザーグループに基づいたリチャージ価格設定の乗数。", "Must be a valid URL": "有効な URL を入力してください", "Must be at least 8 characters": "8文字以上である必要があります", + "Must be http(s). Leave empty to keep the page hidden from the sidebar.": "http(s) である必要があります。空の場合はサイドバーに表示されません。", "My Subscriptions": "マイサブスクリプション", "my-status": "my-status", "MySQL detected": "MySQLが検出されました", @@ -2841,6 +2881,7 @@ "No available Web chat links": "利用可能なWebチャットリンクがありません", "No backup": "バックアップなし", "No base input price": "基本入力価格なし", + "No billing groups configured.": "課金グループが設定されていません。", "No billing records found": "請求記録が見つかりません", "No capabilities reported for this model.": "このモデルには報告されている機能がありません。", "No Change": "変更なし", @@ -2862,6 +2903,7 @@ "No containers": "コンテナがありません", "No content to copy": "コピーする内容がありません", "No custom OAuth providers configured yet.": "カスタムOAuthプロバイダーはまだ設定されていません。", + "No custom pages yet. Click \"Add Custom Page\" to create one.": "カスタムページはまだありません。「カスタムページを追加」をクリックして作成してください。", "No data": "データがありません", "No Data": "データなし", "No data available": "データがありません", @@ -2880,6 +2922,7 @@ "No group": "グループなし", "No group found.": "グループが見つかりません。", "No group-based rate limits configured. Click \"Add group\" to get started.": "グループベースのレート制限が設定されていません。\"グループを追加\" をクリックして開始してください。", + "No groups available for this provider type": "このタイプで利用可能なグループがありません", "No groups match your search": "検索に一致するグループがありません", "No groups yet. Add a group to get started.": "グループはまだありません。グループを追加して開始してください。", "No header overrides configured.": "ヘッダーのオーバーライドが設定されていません。", @@ -2942,9 +2985,13 @@ "No processable upstream model updates for this channel": "このチャネルには処理可能な上流モデル更新がありません", "No products configured. Click \"Add product\" to get started.": "製品が設定されていません。「製品を追加」をクリックして開始してください。", "No products match your search": "検索に一致する製品がありません", + "No provider types are available for your current groups.": "現在のグループで利用可能なタイプがありません。", + "No provider types are available. Types unlock when pricing models match Anthropic / OpenAI / Gemini / xAI for your groups — having channels alone is not enough.": "選択できるタイプがありません。料金のモデルが Anthropic / OpenAI / Gemini / xAI に一致し、利用可能なグループから使える場合に解放されます。チャネルがあるだけでは不十分です。", + "No provider types are available. Types unlock when pricing models match Anthropic / OpenAI for your groups — having channels alone is not enough.": "選択できるタイプがありません。料金のモデルが Anthropic / OpenAI に一致し、利用可能なグループから使える場合に解放されます。チャネルがあるだけでは不十分です。", "No providers available": "利用可能なプロバイダーがありません", "No Quota": "クォータなし", "No ratio differences found": "比率の差異は見つかりませんでした", + "No recent requests for this group.": "このグループに最近のリクエストはありません。", "No recent usage": "最近の使用なし", "No records found. Try adjusting your filters.": "記録が見つかりません。フィルターを調整してみてください。", "No redemption codes available. Create your first redemption code to get started.": "利用可能な引き換えコードがありません。最初の引き換えコードを作成して開始してください。", @@ -2996,6 +3043,7 @@ "Non-zero invitation rewards require compliance confirmation in Payment Gateway settings.": "0 以外の招待報酬には、支払いゲートウェイ設定でのコンプライアンス確認が必要です。", "None": "なし", "noreply@example.com": "noreply@example.com", + "Normal": "正常", "Normalized:": "正規化:", "Not available": "利用できません", "Not backed up": "未バックアップ", @@ -3016,6 +3064,7 @@ "Notification Email": "通知メール", "Notification Method": "通知方法", "Notifications": "通知", + "Now": "現在", "Now a user whose user group is vip creates tokens with different groups and makes one call with each:": "ここで、ユーザーグループが vip のユーザーが異なるグループのトークンを作成し、それぞれ1回ずつ呼び出します:", "Nucleus sampling probability mass": "核サンプリングの累積確率", "Number of codes to create": "作成するコードの数", @@ -3076,6 +3125,7 @@ "Only available for admins. When enabled, you will receive a summary notification via your selected method when the scheduled model check detects upstream model changes or check failures.": "管理者のみ利用可能です。有効にすると、スケジュールされたモデルチェックでアップストリームモデルの変更やチェック失敗が検出された際に、選択した方法で概要通知を受け取ります。", "Only configured combinations are overridden. All other calls keep the billing group base ratio.": "設定された組み合わせだけが上書きされます。それ以外の呼び出しは課金グループの基本倍率のままです。", "Only configured combinations are overridden. All other calls keep the token group base ratio.": "設定済みの組み合わせだけが上書きされます。他の呼び出しはトークングループの基本倍率を維持します。", + "Only enabled pages with a URL are shown in the Extensions sidebar group.": "有効かつ URL のあるページのみ「拡張」グループに表示されます。", "Only enabled parameters are sent with the request.": "有効なパラメータだけがリクエストに送信されます。", "Only enter the site origin, for example https://api.example.com. Do not include any path such as /api/user/epay/notify. Leave blank to use the server address.": "サイトのオリジンのみを入力してください。例: https://api.example.com。/api/user/epay/notify などのパスは含めないでください。空欄の場合はサーバーアドレスを使用します。", "Only Mine": "自分のみ", @@ -3094,6 +3144,7 @@ "Open in new tab": "新しいタブで開く", "Open in New Tab": "新しいタブで開く", "Open menu": "メニューを開く", + "Open mode": "開く方法", "Open release": "リリースを開く", "Open source": "オープンソース", "Open Source": "オープンソース", @@ -3253,6 +3304,7 @@ "Password reset: {{password}}": "パスワードがリセットされました:{{password}}", "Passwords do not match": "パスワードが一致しません", "Passwords don't match.": "パスワードが一致しません。", + "Past": "過去", "Paste Connection Info": "接続情報を貼り付け", "Path": "パス", "Path not set": "パス未設定", @@ -3326,6 +3378,7 @@ "Personal use": "個人利用", "Personal use mode": "個人利用モード", "Pick a date": "日付を選択", + "Pick a provider type, group, model, and client. We create an API key and open the tool for you.": "タイプ・グループ・モデル・クライアントを選ぶと、APIキーを作成してツールを開きます。", "Pick or create both a store and a product before saving.": "保存する前に、ストアと商品の両方を選択または作成してください。", "Ping Interval (seconds)": "Ping間隔(秒)", "Plan": "プラン", @@ -3522,6 +3575,7 @@ "Provider created successfully": "プロバイダーの作成に成功しました", "Provider deleted successfully": "プロバイダーの削除に成功しました", "Provider Name": "プロバイダー名", + "Provider type": "プロバイダータイプ", "Provider type (OpenAI, Anthropic, etc.)": "プロバイダタイプ (OpenAI, Anthropic など)", "Provider updated successfully": "プロバイダーが正常に更新されました", "Provider-specific endpoint, account, and compatibility settings.": "プロバイダー固有のエンドポイント、アカウント、互換性設定です。", @@ -3613,6 +3667,7 @@ "Receive Upstream Model Update Notifications": "アップストリームモデル更新通知を受け取る", "Received": "受信済み", "Received amount": "受け取り額", + "Recent {{count}} records": "直近 {{count}} 件", "Recent maintenance tasks running across instances and their execution status.": "各インスタンスで実行された最近のメンテナンスタスクとその実行状態。", "Recently completed or failed system task runs.": "最近完了または失敗したシステムタスク実行です。", "Recently launched models": "最近リリースされたモデル", @@ -3661,8 +3716,10 @@ "Refresh Cache": "キャッシュ更新", "Refresh credential": "認証情報を更新", "Refresh details": "詳細を更新", + "Refresh every {{seconds}}s": "{{seconds}} 秒ごとに更新", "Refresh failed": "更新に失敗しました", "Refresh interval (minutes)": "更新間隔 (分)", + "Refresh interval (seconds)": "更新間隔(秒)", "Refresh Stats": "統計を更新", "Refreshing...": "更新中...", "Refund": "返金", @@ -3748,6 +3805,7 @@ "Request Header Field": "リクエストヘッダーフィールド", "Request Header Override": "リクエストヘッダー上書き", "Request Header Overrides": "リクエストヘッダーの上書き", + "Request health by billing group for the latest 100 consume/error logs. Green bars show latency; red bars are failures. Badge uses overall success rate (≥95% normal, ≥80% warning, below 80% abnormal).": "課金グループごとの直近 100 件。緑は遅延、赤は失敗。バッジは成功率(≥95% 正常、≥80% 警告、80% 未満は異常)。", "Request ID": "リクエストID", "Request Limits": "リクエスト制限", "Request Model": "リクエストモデル", @@ -3999,6 +4057,7 @@ "Select a color": "色を選択", "Select a group": "グループを選択", "Select a group type": "グループタイプを選択", + "Select a model": "モデルを選択", "Select a model to edit pricing": "料金を編集するモデルを選択", "Select a preset...": "プリセットを選択...", "Select a product": "商品を選択", @@ -4013,6 +4072,7 @@ "Select all (filtered)": "フィルタ結果をすべて選択(S)", "Select all models": "すべてのモデルを選択", "Select All Visible": "表示中のすべてを選択", + "Select an icon": "アイコンを選択", "Select an operation mode and enter the amount": "操作モードを選択し、金額を入力してください", "Select announcement type": "アナウンスメントタイプを選択", "Select at least one field to overwrite.": "上書きするフィールドを少なくとも 1 つ選択してください。", @@ -4046,6 +4106,7 @@ "Select models or add custom ones": "モデルを選択するか、カスタムモデルを追加", "Select models to process. Unselected \"add\" models will be ignored.": "処理するモデルを選択してください。未選択の「追加」モデルは無視されます。", "Select models to run batch tests.": "バッチテストを実行するモデルを選択してください。", + "Select open mode": "開く方法を選択", "Select or enter color value": "色の値を選択または入力", "Select or enter method identifier": "決済方法の識別子を選択または入力", "Select or enter model name": "モデル名を選択または入力", @@ -4071,6 +4132,7 @@ "Select theme preset": "テーマプリセットを選択", "Select time granularity": "時間の粒度を選択", "Select vendor": "ベンダーを選択", + "Select visibility": "表示範囲を選択", "Selectable groups": "選択可能なグループ", "selected": "選択済み", "Selected {{count}}": "{{count}} 件選択済み", @@ -4154,6 +4216,8 @@ "Showcase core capabilities with demo credentials and limited access.": "デモ用の認証情報と制限付きアクセスでコア機能を紹介します。", "Showing": "表示", "showing •": "表示中 •", + "Shown in the console sidebar. Maximum 100 characters.": "コンソールのサイドバーに表示されます。最大 100 文字。", + "Shows a group-level request heartbeat chart under Extensions. Failed requests require ERROR_LOG_ENABLED.": "拡張メニューにグループ別リクエスト心拍チャートを表示します。失敗記録には ERROR_LOG_ENABLED が必要です。", "Sidebar": "サイドバー", "Sidebar collapsed by default for new users": "新規ユーザー向けにサイドバーをデフォルトで折りたたむ", "Sidebar modules": "サイドバーモジュール", @@ -4446,6 +4510,7 @@ "The bound Product powers wallet top-ups: when a user enters any amount, new-api runs the checkout against this single Pancake product and overrides the price per session — no need to pre-create $1 / $5 / $10 SKUs.": "紐付け済み商品はウォレットチャージに使用されます。ユーザーが任意の金額を入力すると、new-api はこの単一の Pancake 商品でチェックアウトを実行し、セッションごとに価格を上書きします。$1 / $5 / $10 の SKU を事前作成する必要はありません。", "The bound Store is the parent container for every Pancake product new-api creates from this admin — both the wallet top-up product and any subscription-plan products. One store is enough; pin a different one only if you genuinely run separate Pancake catalogs.": "紐付け済みストアは、この管理画面から new-api が作成するすべての Pancake 商品の親コンテナです。ウォレットチャージ商品とサブスクリプションプラン商品が含まれます。通常は 1 つのストアで十分です。別々の Pancake カタログを本当に運用する場合のみ別のストアを固定してください。", "The deployment node that handled the requests": "リクエストを処理したデプロイノード", + "The download will use the redemption name as the filename.": "ダウンロードファイル名には引き換えコードの名称が使用されます。", "The effective domain for Passkey registration. Must match the current domain or be its parent domain.": "Passkey登録のための有効なドメイン。現在のドメインまたはその親ドメインと一致する必要があります。", "The entered text does not match the required text.": "入力したテキストが必要なテキストと一致しません。", "The environment (test vs production) is decided by the key you paste here — use the Test key while integrating, then swap to the Production key when going live.": "環境(テスト/本番)はここに貼り付けるキーで決まります。統合中はテストキーを使用し、本番公開時に本番キーへ切り替えてください。", @@ -4458,6 +4523,7 @@ "The name displayed across the application": "アプリケーション全体に表示される名前", "The public URL of your server, used for OAuth callbacks, webhooks, and other external integrations": "OAuthコールバック、Webhook、その他の外部統合に使用されるサーバーの公開URL", "The requested chat preset does not exist or has been removed.": "要求されたチャットプリセットは存在しないか、削除されました。", + "The requested page does not exist, is disabled, or has no URL configured.": "要求されたページは存在しないか、無効か、URL が未設定です。", "The reset request stays disabled until a credit is available.": "リセット回数が利用可能になるまで、リセット要求は無効です。", "The setup wizard will use this database during initialization.": "セットアップウィザードは初期化時にこのデータベースを使用します。", "The site is not available at the moment.": "現在、このサイトは利用できません。", @@ -4496,6 +4562,7 @@ "This channel type requires additional configuration": "このチャネルタイプには追加設定が必要です", "This confirmation unlocks payment, redemption code, subscription plan, and invitation reward features. Please read the statements carefully.": "この確認により、支払い、引換コード、サブスクリプションプラン、招待報酬の機能が解除されます。各項目をよく読んでください。", "This controls model request rate limiting. Web/API route throttling is configured by environment variables and may still return 429.": "これはモデルリクエストのレート制限を制御します。Web/API ルートのスロットリングは環境変数で設定され、引き続き 429 を返す場合があります。", + "This custom page will be removed from the list.": "このカスタムページは一覧から削除されます。", "This data may be unreliable, use with caution": "このデータは信頼できない可能性があります。注意して使用してください", "This device does not support Passkey": "このデバイスはPasskeyをサポートしていません", "This device does not support Passkey verification.": "このデバイスはPasskey認証をサポートしていません。", @@ -4515,6 +4582,7 @@ "This model is not available in any group, or no group pricing information is configured.": "このモデルはどのグループでも利用できないか、グループの料金情報が設定されていません。", "This month": "今月", "This page has not been created yet.": "このページはまだ作成されていません。", + "This page opens in a new browser tab because the target site cannot be embedded.": "対象サイトを埋め込めないため、新しいタブで開きます。", "This plan does not allow balance redemption": "このプランでは残高での交換は許可されていません", "This project must be used in compliance with the": "このプロジェクトは、以下を遵守して使用する必要があります", "This removes {{count}} failed models from this channel. This action cannot be undone.": "この操作はこのチャンネルから失敗した {{count}} 個のモデルを削除します。元に戻せません。", @@ -4570,6 +4638,7 @@ "times": "回", "Timing": "所要時間", "Tip": "ヒント", + "Title": "タイトル", "to access this resource.": "このリソースにアクセスするには。", "To Anthropic Messages": "Anthropic Messages へ", "to confirm": "確認する", @@ -4728,6 +4797,7 @@ "UI granularity only — data is still aggregated hourly": "UIの粒度のみ — データは引き続き時間単位で集計されます", "Unable to estimate price for this deployment.": "このデプロイメントの価格を推定できません。", "Unable to generate chat link. Please contact your administrator.": "チャットリンクを生成できません。管理者にご連絡ください。", + "Unable to load availability": "可用性データを読み込めません", "Unable to load groups": "グループをロードできません", "Unable to load rankings": "ランキングを読み込めません", "Unable to load rankings data": "ランキングデータを読み込めません", @@ -4858,6 +4928,7 @@ "USD Exchange Rate": "USD 為替レート", "USD price per 1M input tokens.": "100万入力トークンあたりのUSD価格。", "USD price per 1M tokens.": "100万トークンあたりのUSD価格。", + "Use “Open in new tab” for sites that block iframe embedding (for example Taobao / Xianyu short links).": "iframe 埋め込みを拒否するサイトでは「新しいタブで開く」を選んでください。", "Use +: to add a group, -: to remove a default selectable group, or no prefix to append a group.": "+: はグループ追加、-: はデフォルト選択可能グループの削除、接頭辞なしはグループ追記に使います。", "Use a compatible browser or device with biometric authentication or a security key to register a Passkey.": "生体認証またはセキュリティキーを備えた互換性のあるブラウザまたはデバイスを使用して、パスキーを登録してください。", "Use a different stable value for each instance, then restart the service.": "インスタンスごとに異なる安定した値を使用し、その後サービスを再起動してください。", @@ -5010,6 +5081,7 @@ "Violation Marker": "違反マーカー", "vip": "vip", "VIP users with premium access": "プレミアムアクセス権を持つVIPユーザー", + "Visibility": "表示範囲", "Visible": "表示", "Vision": "ビジョン", "Vision, image / video, document chat": "ビジョン・画像/動画・ドキュメントチャット", diff --git a/web/default/src/i18n/locales/ru.json b/web/default/src/i18n/locales/ru.json index aa5051c00c28..c0c2c1df6e91 100644 --- a/web/default/src/i18n/locales/ru.json +++ b/web/default/src/i18n/locales/ru.json @@ -38,6 +38,8 @@ "{{count}} channel(s) enabled": "Включено {{count}} каналов", "{{count}} channel(s) failed to disable": "Не удалось отключить {{count}} каналов", "{{count}} channel(s) failed to enable": "Не удалось включить {{count}} каналов", + "{{count}} custom pages deleted. Click \"Save Settings\" to apply.": "Удалено страниц: {{count}}. Нажмите «Сохранить», чтобы применить.", + "{{count}} custom pages will be removed from the list.": "Из списка будет удалено страниц: {{count}}.", "{{count}} days ago": "{{count}} дней назад", "{{count}} days remaining": "Осталось {{count}} дней", "{{count}} disabled channel(s) deleted": "Удалено {{count}} отключённых каналов", @@ -118,6 +120,8 @@ "80,443,8080": "80,443,8080", "A billing multiplier. Lower ratios mean lower API call costs.": "Множитель тарификации. Чем ниже коэффициент, тем ниже стоимость вызовов API.", "A focused home for keys, balance, routing, and service health.": "Единый экран для ключей, баланса, маршрутов и состояния сервиса.", + "A recommended model is selected automatically. You can change it.": "Рекомендуемая модель выбрана автоматически. Её можно изменить.", + "Abnormal": "Сбой", "About": "О проекте", "About {{days}} days left": "Примерно {{days}} дней", "Accept Unpriced Models": "Принимать модели без цены", @@ -174,6 +178,7 @@ "Add Condition": "Добавить условие", "Add credits": "Добавить средства", "Add custom model \"{{value}}\"": "Добавить пользовательскую модель «{{value}}»", + "Add Custom Page": "Добавить страницу", "Add discount tier": "Добавить уровень скидки", "Add each model or tag you want to include.": "Добавьте каждую модель или тег, который хотите включить.", "Add FAQ": "Добавить вопрос-ответ", @@ -239,6 +244,7 @@ "Administer user accounts and roles.": "Управление учетными записями пользователей и ролями.", "Administrator account": "Учетная запись администратора", "Administrator username": "Имя пользователя администратора", + "Admins only": "Только админы", "Advance next reset time": "Перенести следующее время сброса", "Advanced": "Расширенные", "Advanced Configuration": "Расширенная конфигурация", @@ -387,6 +393,7 @@ "API Key (Sandbox)": "API-ключ (Песочница)", "API Key *": "Ключ API *", "API Key created successfully": "API ключ успешно создан", + "API key created. Opening the selected tool...": "Ключ API создан. Открываем выбранный инструмент...", "API Key deleted successfully": "API ключ успешно удален", "API Key disabled successfully": "API ключ успешно отключен", "API Key enabled successfully": "API ключ успешно включен", @@ -520,7 +527,9 @@ "Automatically replaces upstream callback URLs with the server address.": "Автоматически заменяет URL обратных вызовов upstream на адрес сервера.", "Automatically selects the best available group with circuit breaker mechanism": "Автоматически выбирает лучшую доступную группу с механизмом circuit breaker", "Automatically sync model list when upstream changes are detected": "Автоматически синхронизировать список моделей при обнаружении изменений у провайдера", + "Availability": "Доступность", "Availability (last 24h)": "Доступность (последние 24 ч)", + "Availability Monitor": "Мониторинг доступности", "Available": "Доступно", "Available credits are ordered by soonest expiration.": "Доступные сбросы отсортированы по ближайшему истечению.", "Available disk space": "Доступное дисковое пространство", @@ -535,6 +544,7 @@ "Average tokens per second sustained per group": "Средняя устойчивая пропускная способность (токенов/с) по группам", "Average TPM": "Среднее число транзакций в минуту", "Average TTFT": "Средний TTFT", + "Avg latency": "Средняя задержка", "AWS": "AWS", "AWS Bedrock Claude Compat": "AWS Bedrock Claude совместимость", "AWS Key Format": "Формат ключа AWS", @@ -814,6 +824,8 @@ "Choose the default charts, range, and time granularity for model analytics.": "Выберите графики, диапазон и временную детализацию по умолчанию для аналитики моделей.", "Choose where to fetch upstream metadata.": "Выберите, откуда получать метаданные вышестоящего источника.", "Choose which charts are selected by default when opening model analytics.": "Выберите графики, которые будут выбраны по умолчанию при открытии аналитики моделей.", + "Choose who can see the Availability Monitor entry in the Extensions sidebar.": "Кто видит мониторинг в меню Extensions.", + "Choose who can see this page in the Extensions sidebar.": "Кто видит эту страницу в меню Extensions.", "Clamped to": "Ограничено до", "Classic (Legacy Frontend)": "Классический (Старый интерфейс)", "Claude": "Клод", @@ -949,6 +961,7 @@ "Configuration for Epay payment integration": "Конфигурация для интеграции платежей Epay", "Configuration for Stripe payment integration": "Конфигурация для интеграции платежей Stripe", "Configuration required": "Требуется настройка", + "Configuration tool": "Инструмент настройки", "Configure": "Настройка", "Configure a Creem product for user recharge options.": "Настройте продукт Creem для опций пополнения пользователя.", "Configure a custom ratio for when users use a specific token group.": "Настроить пользовательский коэффициент при использовании определённой группы токенов.", @@ -974,6 +987,8 @@ "Configure rate limiting rules for a specific user group.": "Настроить правила ограничения скорости для конкретной группы пользователей.", "Configure routes": "Настроить маршруты", "Configure the ratio for this group.": "Настроить коэффициент для этой группы.", + "Configure the sidebar title, icon, embed URL, status, and sort order.": "Настройте заголовок, значок, URL встраивания, статус и порядок.", + "Configure the sidebar title, icon, URL, open mode, status, and sort order.": "Настройте заголовок, значок, URL, способ открытия, статус и порядок.", "Configure upstream providers and routing.": "Настроить провайдеров верхнего уровня и маршрутизацию.", "Configure Waffo Pancake hosted checkout integration for USD-priced top-ups": "Настроить хостовую интеграцию Waffo Pancake (hosted checkout) для пополнений в USD", "Configure Waffo payment aggregation platform integration": "Настроить интеграцию платёжной платформы Waffo", @@ -981,6 +996,7 @@ "Configure your account preferences and integrations": "Настроить параметры и интеграции вашей учетной записи", "Configured as PayMethods JSON. The type value decides which payment flow is used: stripe for Stripe, waffo_pancake for Waffo Pancake, and other values are sent to Epay as the type parameter.": "Сохраняется как JSON PayMethods. Значение type определяет платежный сценарий: stripe для Stripe, waffo_pancake для Waffo Pancake, остальные значения отправляются в Epay как параметр type.", "Configured routes and latency checks": "Настроенные маршруты и проверки задержки", + "Configuring...": "Настройка...", "Confirm": "Подтверждение", "Confirm Action": "Подтвердить действие", "Confirm and enable": "Подтвердить и включить", @@ -1015,6 +1031,7 @@ "Conflict": "Противоречие", "Connect": "Подключение", "Connect through OpenAI, Claude, Gemini, and other compatible API routes": "Подключайтесь через OpenAI, Claude, Gemini и другие совместимые API-маршруты", + "Connect tool": "Подключить инструмент", "Connected to io.net service normally.": "Соединение с сервисом io.net установлено.", "Connection closed": "Соединение закрыто", "Connection error": "Ошибка соединения", @@ -1115,6 +1132,7 @@ "Cost = model price × that one ratio. Nothing else from the group settings enters the formula.": "Стоимость = цена модели × этот единственный коэффициент. Другие настройки групп в формуле не участвуют.", "Cost in USD per request, regardless of tokens used.": "Стоимость в долларах США за запрос, независимо от использованных токенов.", "Cost Tracking": "Отслеживание затрат", + "Could not load pricing data. Open the pricing page or refresh and try again.": "Не удалось загрузить данные тарифов. Откройте страницу цен или обновите и попробуйте снова.", "Count must be between {{min}} and {{max}}": "Количество должно быть от {{min}} до {{max}}", "Coze": "Coze", "CPU": "ЦП", @@ -1126,6 +1144,7 @@ "Create account": "Создать аккаунт", "Create an account": "Создать аккаунт", "Create an API key to unlock the real request": "Создайте API-ключ, чтобы открыть реальный запрос", + "Create and configure": "Создать и настроить", "Create and review invite or credit codes.": "Создать и просмотреть коды приглашений или кредитов.", "Create API Key": "Создать ключ API", "Create cache": "Создать кеш", @@ -1214,6 +1233,12 @@ "Custom multipliers when specific user groups use specific token groups. Example: VIP users get 0.9x rate when using \"edit_this\" group tokens.": "Пользовательские множители, когда определенные группы пользователей используют определенные группы токенов. Пример: VIP-пользователи получают ставку 0.9x при использовании токенов группы \"edit_this\".", "Custom OAuth": "Пользовательский OAuth", "Custom OAuth Providers": "Пользовательские OAuth-провайдеры", + "Custom page added. Click \"Save Settings\" to apply.": "Страница добавлена. Нажмите «Сохранить», чтобы применить.", + "Custom page deleted. Click \"Save Settings\" to apply.": "Страница удалена. Нажмите «Сохранить», чтобы применить.", + "Custom page not found": "Пользовательская страница не найдена", + "Custom page updated. Click \"Save Settings\" to apply.": "Страница обновлена. Нажмите «Сохранить», чтобы применить.", + "Custom Pages": "Пользовательские страницы", + "Custom pages saved successfully": "Пользовательские страницы сохранены", "Custom Seconds": "Пользовательские секунды", "Custom sidebar section": "Пользовательский раздел боковой панели", "Custom Time Range": "Пользовательский диапазон времени", @@ -1428,7 +1453,9 @@ "Do not wait one second between polling async tasks for this channel": "Не ждать одну секунду между опросами асинхронных задач для этого канала", "Do regex replacement in the target field": "Выполнить замену по регулярному выражению в целевом поле", "Do string replacement in the target field": "Выполнить замену строки в целевом поле", + "Do you want to download the created redemption codes as a text file?": "Скачать созданные коды пополнения в виде текстового файла?", "Docs": "Документы", + "Documentation": "Документация", "Documentation Link": "Ссылка на документацию", "Documentation or external knowledge base.": "Документация или внешняя база знаний.", "does not exist or might have been removed.": "не существует или, возможно, был удален.", @@ -1521,6 +1548,7 @@ "Edit Channel": "Редактировать канал", "Edit channel routing": "Изменение маршрутизации каналов", "Edit chat preset": "Редактировать пресет чата", + "Edit Custom Page": "Изменить страницу", "Edit discount tier": "Редактировать уровень скидки", "Edit FAQ": "Редактировать FAQ", "Edit group": "Редактировать группу", @@ -1557,6 +1585,7 @@ "Email Field": "Поле email", "Email Verification": "Верификация Email", "Email, summarisation, knowledge work": "Электронная почта, резюме, knowledge work", + "Embed in console": "Встроить в консоль", "Embeddings": "Встраивания", "Empty": "Пусто", "Empty value will be saved as {}.": "Пустое значение будет сохранено как {}.", @@ -1564,6 +1593,7 @@ "Enable {{parameter}}": "Включить {{parameter}}", "Enable 2FA": "Включить 2FA", "Enable All": "Включить все", + "Enable availability monitor": "Включить мониторинг", "Enable check-in feature": "Включить функцию прибытия", "Enable Data Dashboard": "Включить панель данных", "Enable demo mode with limited functionality": "Включить демонстрационный режим с ограниченной функциональностью", @@ -1606,6 +1636,7 @@ "Enabled": "Включено", "Enabled all channels with tag: {{tag}}": "Все каналы с тегом {{tag}} включены", "Enabled channels with tag {{tag}}": "Включены каналы с тегом {{tag}}", + "Enabled pages with a URL appear under the Extensions group in the console sidebar and open as embedded pages.": "Включённые страницы с URL появляются в группе «Расширения» боковой панели и открываются как встроенные.", "Enabled Status": "Статус включения", "Enabling...": "Включается...", "Encourages introducing new topics": "Поощряет введение новых тем", @@ -1721,6 +1752,7 @@ "Estimated cost": "Примерная стоимость", "Estimated quota cost": "Ориентир стоимости квоты", "Every group name in the pricing table can be used in two places: on a user (the user group, assigned by admins) and on a token (the token group, chosen when creating the token). Same name pool, two different jobs.": "Каждое имя группы из таблицы тарифов используется в двух местах: у пользователя (группа пользователя, назначается администратором) и у токена (группа токена, выбирается при создании). Один набор имён — две разные роли.", + "Everyone": "Все", "Everything configured for this group, in one place.": "Все настройки этой группы в одном месте.", "Exact": "Точное", "Exact Match": "Точное совпадение", @@ -1765,6 +1797,7 @@ "Extend deployment": "Продлить развертывание", "Extend failed": "Не удалось продлить", "Extended successfully": "Продлено успешно", + "Extensions": "Расширения", "External Device": "Внешнее устройство", "External link for users to purchase quota": "Внешняя ссылка для пользователей для покупки квоты", "External operations": "Внешние операции", @@ -1843,6 +1876,7 @@ "Failed to initialize system": "Не удалось инициализировать систему", "Failed to load": "Не удалось загрузить", "Failed to load API keys": "Не удалось загрузить API ключи", + "Failed to load availability": "Ошибка загрузки доступности", "Failed to load billing history": "Не удалось загрузить историю платежей", "Failed to load enabled models": "Не удалось загрузить включённые модели", "Failed to load home page content": "Не удалось загрузить содержимое главной страницы", @@ -1874,6 +1908,7 @@ "Failed to save": "Не удалось сохранить", "Failed to save announcements": "Не удалось сохранить объявления", "Failed to save API info": "Не удалось сохранить информацию API", + "Failed to save custom pages": "Не удалось сохранить пользовательские страницы", "Failed to save FAQ": "Не удалось сохранить FAQ", "Failed to save Uptime Kuma groups": "Не удалось сохранить группы Uptime Kuma", "Failed to search API keys": "Не удалось найти API ключи", @@ -1965,8 +2000,8 @@ "Filter by MjProxy task ID": "Фильтр по ID задачи MjProxy", "Filter by model name...": "Фильтр по имени модели...", "Filter by model...": "Фильтровать по модели...", - "Filter by name or ID...": "Фильтр по имени или ID...", "Filter by name, ID, or key...": "Фильтровать по имени, ID или ключу...", + "Filter by name, ID, or redemption code...": "Фильтр по имени, ID или коду активации...", "Filter by name...": "Фильтр по имени...", "Filter by node": "Фильтр по узлу", "Filter by price field": "Фильтр по полю цены", @@ -2216,6 +2251,7 @@ "How It Works": "Как это работает", "How model mapping works": "Как работает сопоставление моделей", "How much to charge for each US dollar of balance (Epay)": "Сколько взимать за каждый доллар США баланса (Epay)", + "How often the Availability Monitor page automatically reloads data. Allowed range: 5–3600 seconds.": "Как часто страница мониторинга доступности автоматически перезагружает данные. Допустимый диапазон: 5–3600 секунд.", "How this model name should match requests": "Как это имя модели должно соответствовать запросам", "How to deliver the resulting image": "Способ доставки изображения", "How to get an io.net API Key": "Как получить ключ API io.net", @@ -2257,6 +2293,7 @@ "If connecting to upstream One API or New API relay projects, use OpenAI type instead unless you know what you are doing": "При подключении к upstream One API или проектам-ретрансляторам New API используйте тип OpenAI, если только вы точно знаете, что делаете", "If default auto group is enabled, newly created tokens start with auto instead of an empty group.": "Если группа auto включена по умолчанию, новые токены создаются с auto вместо пустой группы.", "If the affinity channel fails and retry succeeds on another channel, update affinity to the successful channel.": "Если привязанный канал не работает и повторная попытка удалась через другой канал, привязка обновляется на успешный канал.", + "If the app did not open, install the tool and use this API key manually:": "Если приложение не открылось, установите инструмент и используйте этот ключ вручную:", "If this keeps happening, please report it on GitHub Issues.": "Если проблема повторяется, сообщите о ней в GitHub Issues.", "If you provide generative AI services to the public in mainland China, you will fulfill legal obligations including filing, security assessment, content safety, complaint handling, generated content labeling, log retention, and personal information protection.": "Если вы предоставляете услуги генеративного ИИ населению материкового Китая, вы будете выполнять юридические обязанности, включая регистрацию, оценку безопасности, безопасность контента, обработку жалоб, маркировку сгенерированного контента, хранение журналов и защиту персональных данных.", "Ignore": "Игнорировать", @@ -2487,6 +2524,7 @@ "Load template...": "Загрузить шаблон...", "Loader": "Загрузчик", "Loading": "Загрузка", + "Loading available providers...": "Загрузка доступных типов…", "Loading channel details": "Загрузка сведений о канале", "Loading configuration": "Загрузка конфигурации", "Loading content settings...": "Загрузка настроек контента...", @@ -2527,6 +2565,7 @@ "Logs": "Журналы", "Look for a special ratio rule matching this user group and this billing group. If one exists, use its ratio. Otherwise use the billing group base ratio from the pricing table.": "Найдите правило особого коэффициента для этой группы пользователя и тарифной группы. Если оно есть — используется его коэффициент, иначе базовый коэффициент тарифной группы.", "Low balance": "Низкий баланс", + "Lower numbers appear first in the sidebar.": "Меньшие числа отображаются выше в боковой панели.", "Lowest median first-token latency": "Минимальная медианная задержка первого токена", "m": "m", "Maintenance": "Обслуживание", @@ -2771,6 +2810,7 @@ "Multipliers for recharge pricing based on user groups.": "Множители для ценообразования пополнения на основе групп пользователей.", "Must be a valid URL": "Должен быть действительный URL", "Must be at least 8 characters": "Должно быть не менее 8 символов", + "Must be http(s). Leave empty to keep the page hidden from the sidebar.": "Должен быть http(s). Оставьте пустым, чтобы скрыть страницу.", "My Subscriptions": "Мои подписки", "my-status": "мой-статус", "MySQL detected": "Обнаружен MySQL", @@ -2841,6 +2881,7 @@ "No available Web chat links": "Нет доступных веб-ссылок для чата", "No backup": "Нет резервной копии", "No base input price": "Нет базовой цены входа", + "No billing groups configured.": "Группы биллинга не настроены.", "No billing records found": "Записи о выставлении счетов не найдены", "No capabilities reported for this model.": "Для этой модели не указаны возможности.", "No Change": "Без изменений", @@ -2862,6 +2903,7 @@ "No containers": "Нет контейнеров", "No content to copy": "Нет содержимого для копирования", "No custom OAuth providers configured yet.": "Пользовательские поставщики OAuth еще не настроены.", + "No custom pages yet. Click \"Add Custom Page\" to create one.": "Пользовательских страниц пока нет. Нажмите «Добавить страницу».", "No data": "Нет данных", "No Data": "Нет данных", "No data available": "Нет доступных данных", @@ -2880,6 +2922,7 @@ "No group": "Без группы", "No group found.": "Группа не найдена.", "No group-based rate limits configured. Click \"Add group\" to get started.": "Групповые лимиты скорости не настроены. Нажмите \"Добавить группу\", чтобы начать.", + "No groups available for this provider type": "Нет групп для этого типа", "No groups match your search": "Нет групп, соответствующих вашему поиску", "No groups yet. Add a group to get started.": "Групп пока нет. Добавьте группу, чтобы начать.", "No header overrides configured.": "Нет настроенных переопределений заголовков.", @@ -2942,9 +2985,13 @@ "No processable upstream model updates for this channel": "Нет обрабатываемых обновлений моделей для этого канала", "No products configured. Click \"Add product\" to get started.": "Продукты не настроены. Нажмите \"Добавить продукт\", чтобы начать.", "No products match your search": "Нет продуктов, соответствующих вашему поиску", + "No provider types are available for your current groups.": "Для ваших текущих групп нет доступных типов.", + "No provider types are available. Types unlock when pricing models match Anthropic / OpenAI / Gemini / xAI for your groups — having channels alone is not enough.": "Нет доступных типов. Типы открываются, когда модели в тарифах соответствуют Anthropic / OpenAI / Gemini / xAI для ваших групп — одних каналов недостаточно.", + "No provider types are available. Types unlock when pricing models match Anthropic / OpenAI for your groups — having channels alone is not enough.": "Нет доступных типов. Типы открываются, когда модели в тарифах соответствуют Anthropic / OpenAI для ваших групп — одних каналов недостаточно.", "No providers available": "Нет доступных провайдеров", "No Quota": "Нет квоты", "No ratio differences found": "Различия в коэффициентах не найдены", + "No recent requests for this group.": "Нет недавних запросов для группы.", "No recent usage": "Нет недавнего использования", "No records found. Try adjusting your filters.": "Записи не найдены. Попробуйте изменить фильтры.", "No redemption codes available. Create your first redemption code to get started.": "Нет доступных кодов активации. Создайте свой первый код активации, чтобы начать.", @@ -2996,6 +3043,7 @@ "Non-zero invitation rewards require compliance confirmation in Payment Gateway settings.": "Ненулевые награды за приглашения требуют подтверждения соответствия в настройках платежного шлюза.", "None": "Нет", "noreply@example.com": "noreply@example.com", + "Normal": "Норма", "Normalized:": "Нормализовано:", "Not available": "Недоступно", "Not backed up": "Не сохранено", @@ -3016,6 +3064,7 @@ "Notification Email": "Электронная почта для уведомлений", "Notification Method": "Метод уведомления", "Notifications": "Уведомления", + "Now": "Сейчас", "Now a user whose user group is vip creates tokens with different groups and makes one call with each:": "Теперь пользователь с группой vip создаёт токены с разными группами и делает по одному вызову с каждым:", "Nucleus sampling probability mass": "Накопленная вероятность для nucleus-сэмплинга", "Number of codes to create": "Количество кодов для создания", @@ -3076,6 +3125,7 @@ "Only available for admins. When enabled, you will receive a summary notification via your selected method when the scheduled model check detects upstream model changes or check failures.": "Доступно только для администраторов. При включении вы будете получать сводное уведомление выбранным способом, когда запланированная проверка моделей обнаружит изменения в вышестоящих моделях или сбои проверки.", "Only configured combinations are overridden. All other calls keep the billing group base ratio.": "Переопределяются только настроенные комбинации. Остальные вызовы используют базовый коэффициент тарифной группы.", "Only configured combinations are overridden. All other calls keep the token group base ratio.": "Переопределяются только настроенные комбинации. Остальные вызовы используют базовый коэффициент группы токена.", + "Only enabled pages with a URL are shown in the Extensions sidebar group.": "В группе «Расширения» показываются только включённые страницы с URL.", "Only enabled parameters are sent with the request.": "С запросом отправляются только включенные параметры.", "Only enter the site origin, for example https://api.example.com. Do not include any path such as /api/user/epay/notify. Leave blank to use the server address.": "Введите только origin сайта, например https://api.example.com. Не добавляйте пути, например /api/user/epay/notify. Оставьте пустым, чтобы использовать адрес сервера.", "Only Mine": "Только мои", @@ -3094,6 +3144,7 @@ "Open in new tab": "Открыть в новой вкладке", "Open in New Tab": "Открыть в новой вкладке", "Open menu": "Открыть меню", + "Open mode": "Способ открытия", "Open release": "Открыть выпуск", "Open source": "Открытый исходный код", "Open Source": "Открытый исходный код", @@ -3253,6 +3304,7 @@ "Password reset: {{password}}": "Пароль сброшен: {{password}}", "Passwords do not match": "Пароли не совпадают", "Passwords don't match.": "Пароли не совпадают.", + "Past": "Прошлое", "Paste Connection Info": "Вставить данные подключения", "Path": "Путь", "Path not set": "Путь не задан", @@ -3326,6 +3378,7 @@ "Personal use": "Личное использование", "Personal use mode": "Режим личного использования", "Pick a date": "Выберите дату", + "Pick a provider type, group, model, and client. We create an API key and open the tool for you.": "Выберите тип, группу, модель и клиент. Мы создадим ключ API и откроем инструмент.", "Pick or create both a store and a product before saving.": "Перед сохранением выберите или создайте и магазин, и продукт.", "Ping Interval (seconds)": "Интервал Ping (секунды)", "Plan": "План", @@ -3522,6 +3575,7 @@ "Provider created successfully": "Поставщик успешно создан", "Provider deleted successfully": "Поставщик успешно удален", "Provider Name": "Имя поставщика", + "Provider type": "Тип провайдера", "Provider type (OpenAI, Anthropic, etc.)": "Тип провайдера (OpenAI, Anthropic и т.д.)", "Provider updated successfully": "Поставщик успешно обновлен", "Provider-specific endpoint, account, and compatibility settings.": "Настройки endpoint, аккаунта и совместимости для конкретного провайдера.", @@ -3613,6 +3667,7 @@ "Receive Upstream Model Update Notifications": "Получать уведомления об обновлениях вышестоящих моделей", "Received": "Получено", "Received amount": "Полученная сумма", + "Recent {{count}} records": "Последние {{count}} записей", "Recent maintenance tasks running across instances and their execution status.": "Недавние задачи обслуживания, выполняемые на всех экземплярах, и их статус выполнения.", "Recently completed or failed system task runs.": "Недавние запуски системных задач, завершенные или завершившиеся с ошибкой.", "Recently launched models": "Недавно запущенные модели", @@ -3661,8 +3716,10 @@ "Refresh Cache": "Обновить кэш", "Refresh credential": "Обновить учётные данные", "Refresh details": "Обновить сведения", + "Refresh every {{seconds}}s": "Обновление каждые {{seconds}} с", "Refresh failed": "Ошибка обновления", "Refresh interval (minutes)": "Интервал обновления (минуты)", + "Refresh interval (seconds)": "Интервал обновления (секунды)", "Refresh Stats": "Обновить статистику", "Refreshing...": "Обновление...", "Refund": "Возврат", @@ -3748,6 +3805,7 @@ "Request Header Field": "Поле заголовка запроса", "Request Header Override": "Переопределение заголовков запроса", "Request Header Overrides": "Переопределения заголовков запроса", + "Request health by billing group for the latest 100 consume/error logs. Green bars show latency; red bars are failures. Badge uses overall success rate (≥95% normal, ≥80% warning, below 80% abnormal).": "Здоровье запросов по группам (100 последних логов). Зелёные — задержка, красные — ошибки. Значок по проценту успеха.", "Request ID": "ID запроса", "Request Limits": "Лимиты запросов", "Request Model": "Запрошенная модель", @@ -3999,6 +4057,7 @@ "Select a color": "Выбрать цвет", "Select a group": "Выбрать группу", "Select a group type": "Выбрать тип группы", + "Select a model": "Выберите модель", "Select a model to edit pricing": "Выберите модель для редактирования тарифа", "Select a preset...": "Выберите предустановку...", "Select a product": "Выберите продукт", @@ -4013,6 +4072,7 @@ "Select all (filtered)": "& Выбрать все отфильтрованные", "Select all models": "Выбрать все модели", "Select All Visible": "Выбрать все видимые", + "Select an icon": "Выберите значок", "Select an operation mode and enter the amount": "Выберите режим операции и введите сумму", "Select announcement type": "Выбрать тип объявления", "Select at least one field to overwrite.": "Выберите хотя бы одно поле для перезаписи.", @@ -4046,6 +4106,7 @@ "Select models or add custom ones": "Выбрать модели или добавить пользовательские", "Select models to process. Unselected \"add\" models will be ignored.": "Выберите модели для обработки. Невыбранные модели «добавить» будут проигнорированы.", "Select models to run batch tests.": "Выберите модели для запуска пакетных тестов.", + "Select open mode": "Выберите способ открытия", "Select or enter color value": "Выбрать или ввести значение цвета", "Select or enter method identifier": "Выберите или введите идентификатор способа", "Select or enter model name": "Выберите или введите имя модели", @@ -4071,6 +4132,7 @@ "Select theme preset": "Выберите пресет темы", "Select time granularity": "Выбрать детализацию времени", "Select vendor": "Выбрать поставщика", + "Select visibility": "Выберите видимость", "Selectable groups": "Выбираемые группы", "selected": "выбрано", "Selected {{count}}": "Выбрано: {{count}}", @@ -4154,6 +4216,8 @@ "Showcase core capabilities with demo credentials and limited access.": "Демонстрация основных возможностей с демо-учётными данными и ограниченным доступом.", "Showing": "Отображать", "showing •": "отображается •", + "Shown in the console sidebar. Maximum 100 characters.": "Отображается в боковой панели. Максимум 100 символов.", + "Shows a group-level request heartbeat chart under Extensions. Failed requests require ERROR_LOG_ENABLED.": "Показывает график запросов по группам в Extensions. Ошибки требуют ERROR_LOG_ENABLED.", "Sidebar": "Боковая панель", "Sidebar collapsed by default for new users": "Боковая панель свернута по умолчанию для новых пользователей", "Sidebar modules": "Модули боковой панели", @@ -4446,6 +4510,7 @@ "The bound Product powers wallet top-ups: when a user enters any amount, new-api runs the checkout against this single Pancake product and overrides the price per session — no need to pre-create $1 / $5 / $10 SKUs.": "Привязанный продукт используется для пополнения кошелька: когда пользователь вводит любую сумму, new-api запускает оплату через этот единственный продукт Pancake и переопределяет цену для каждой сессии — не нужно заранее создавать SKU на $1 / $5 / $10.", "The bound Store is the parent container for every Pancake product new-api creates from this admin — both the wallet top-up product and any subscription-plan products. One store is enough; pin a different one only if you genuinely run separate Pancake catalogs.": "Привязанный магазин является родительским контейнером для всех продуктов Pancake, которые new-api создает из этой админки: как продукта пополнения кошелька, так и продуктов планов подписки. Одного магазина достаточно; выбирайте другой только если действительно ведете отдельные каталоги Pancake.", "The deployment node that handled the requests": "Узел развёртывания, обработавший запросы", + "The download will use the redemption name as the filename.": "Файл будет сохранен с именем, совпадающим с названием кода пополнения.", "The effective domain for Passkey registration. Must match the current domain or be its parent domain.": "Действующий домен для регистрации Passkey. Должен совпадать с текущим доменом или быть его родительским доменом.", "The entered text does not match the required text.": "Введенный текст не совпадает с требуемым.", "The environment (test vs production) is decided by the key you paste here — use the Test key while integrating, then swap to the Production key when going live.": "Окружение (тестовое или рабочее) определяется ключом, который вы вставляете здесь: используйте тестовый ключ при интеграции, затем замените его на рабочий при запуске.", @@ -4458,6 +4523,7 @@ "The name displayed across the application": "Имя, отображаемое в приложении", "The public URL of your server, used for OAuth callbacks, webhooks, and other external integrations": "Публичный URL вашего сервера, используемый для OAuth-перенаправлений, вебхуков и других внешних интеграций", "The requested chat preset does not exist or has been removed.": "Запрошенный предустановленный чат не существует или был удален.", + "The requested page does not exist, is disabled, or has no URL configured.": "Запрошенная страница не существует, отключена или без URL.", "The reset request stays disabled until a credit is available.": "Запрос сброса недоступен, пока нет доступного сброса.", "The setup wizard will use this database during initialization.": "Мастер настройки будет использовать эту базу данных при инициализации.", "The site is not available at the moment.": "Сайт в данный момент недоступен.", @@ -4496,6 +4562,7 @@ "This channel type requires additional configuration": "Для этого типа канала требуется дополнительная конфигурация", "This confirmation unlocks payment, redemption code, subscription plan, and invitation reward features. Please read the statements carefully.": "Это подтверждение разблокирует функции платежей, кодов пополнения, планов подписки и наград за приглашения. Внимательно прочитайте заявления.", "This controls model request rate limiting. Web/API route throttling is configured by environment variables and may still return 429.": "Этот параметр управляет ограничением частоты запросов к моделям. Ограничение маршрутов Web/API настраивается переменными окружения и всё ещё может возвращать 429.", + "This custom page will be removed from the list.": "Эта страница будет удалена из списка.", "This data may be unreliable, use with caution": "Эти данные могут быть ненадежными, используйте с осторожностью", "This device does not support Passkey": "Это устройство не поддерживает Passkey", "This device does not support Passkey verification.": "Это устройство не поддерживает проверку с помощью Passkey.", @@ -4515,6 +4582,7 @@ "This model is not available in any group, or no group pricing information is configured.": "Эта модель недоступна ни в одной группе, или информация о ценах для групп не настроена.", "This month": "В этом месяце", "This page has not been created yet.": "Эта страница еще не создана.", + "This page opens in a new browser tab because the target site cannot be embedded.": "Страница открывается в новой вкладке, так как сайт нельзя встроить.", "This plan does not allow balance redemption": "Этот план не разрешает оплату балансом", "This project must be used in compliance with the": "Этот проект должен использоваться в соответствии с", "This removes {{count}} failed models from this channel. This action cannot be undone.": "Это удалит {{count}} неуспешных моделей из этого канала. Действие необратимо.", @@ -4570,6 +4638,7 @@ "times": "раз", "Timing": "Время", "Tip": "Совет", + "Title": "Название", "to access this resource.": "для доступа к этому ресурсу.", "To Anthropic Messages": "В Anthropic Messages", "to confirm": "для подтверждения", @@ -4728,6 +4797,7 @@ "UI granularity only — data is still aggregated hourly": "Только детализация пользовательского интерфейса — данные по-прежнему агрегируются ежечасно", "Unable to estimate price for this deployment.": "Не удается оценить цену для этого развертывания.", "Unable to generate chat link. Please contact your administrator.": "Не удалось сгенерировать ссылку для чата. Пожалуйста, свяжитесь с вашим администратором.", + "Unable to load availability": "Не удалось загрузить доступность", "Unable to load groups": "Не удалось загрузить группы", "Unable to load rankings": "Не удалось загрузить рейтинги", "Unable to load rankings data": "Не удалось загрузить данные рейтингов", @@ -4858,6 +4928,7 @@ "USD Exchange Rate": "Обменный курс USD", "USD price per 1M input tokens.": "Цена в USD за 1 млн входных токенов.", "USD price per 1M tokens.": "Цена в USD за 1 млн токенов.", + "Use “Open in new tab” for sites that block iframe embedding (for example Taobao / Xianyu short links).": "Для сайтов, блокирующих iframe, выберите «Открыть в новой вкладке».", "Use +: to add a group, -: to remove a default selectable group, or no prefix to append a group.": "Используйте +: для добавления группы, -: для удаления выбираемой по умолчанию группы, без префикса — для добавления в конец.", "Use a compatible browser or device with biometric authentication or a security key to register a Passkey.": "Используйте совместимый браузер или устройство с биометрической аутентификацией или ключ безопасности для регистрации ключа доступа.", "Use a different stable value for each instance, then restart the service.": "Используйте разные стабильные значения для каждого экземпляра, затем перезапустите сервис.", @@ -5010,6 +5081,7 @@ "Violation Marker": "Маркер нарушения", "vip": "vip", "VIP users with premium access": "VIP-пользователи с премиум-доступом", + "Visibility": "Видимость", "Visible": "Видима", "Vision": "Зрение", "Vision, image / video, document chat": "Зрение, изображения / видео, чат по документам", diff --git a/web/default/src/i18n/locales/vi.json b/web/default/src/i18n/locales/vi.json index 342322b8b6e3..f1af1d790607 100644 --- a/web/default/src/i18n/locales/vi.json +++ b/web/default/src/i18n/locales/vi.json @@ -38,6 +38,8 @@ "{{count}} channel(s) enabled": "Đã bật {{count}} kênh", "{{count}} channel(s) failed to disable": "{{count}} kênh không thể tắt", "{{count}} channel(s) failed to enable": "{{count}} kênh không thể bật", + "{{count}} custom pages deleted. Click \"Save Settings\" to apply.": "Đã xóa {{count}} trang. Nhấn “Lưu cài đặt” để áp dụng.", + "{{count}} custom pages will be removed from the list.": "{{count}} trang tùy chỉnh sẽ bị xóa khỏi danh sách.", "{{count}} days ago": "{{count}} ngày trước", "{{count}} days remaining": "{{count}} days remaining", "{{count}} disabled channel(s) deleted": "Đã xóa {{count}} kênh đã tắt", @@ -118,6 +120,8 @@ "80,443,8080": "80,443,8080", "A billing multiplier. Lower ratios mean lower API call costs.": "Hệ số tính phí. Tỷ lệ càng thấp thì chi phí gọi API càng thấp.", "A focused home for keys, balance, routing, and service health.": "Trang tổng quan tập trung cho khóa, số dư, định tuyến và trạng thái dịch vụ.", + "A recommended model is selected automatically. You can change it.": "Mô hình đề xuất đã được chọn sẵn. Bạn có thể đổi.", + "Abnormal": "Bất thường", "About": "Giới thiệu", "About {{days}} days left": "Còn khoảng {{days}} ngày", "Accept Unpriced Models": "Chấp nhận các Mô hình chưa định giá", @@ -174,6 +178,7 @@ "Add Condition": "Thêm điều kiện", "Add credits": "Thêm tín dụng", "Add custom model \"{{value}}\"": "Thêm mô hình tùy chỉnh \"{{value}}\"", + "Add Custom Page": "Thêm trang tùy chỉnh", "Add discount tier": "Thêm bậc giảm giá", "Add each model or tag you want to include.": "Thêm mỗi mô hình hoặc thẻ bạn muốn đưa vào.", "Add FAQ": "Thêm FAQ", @@ -239,6 +244,7 @@ "Administer user accounts and roles.": "Quản lý tài khoản người dùng và vai trò.", "Administrator account": "Tài khoản quản trị viên", "Administrator username": "Tên người dùng quản trị viên", + "Admins only": "Chỉ quản trị", "Advance next reset time": "Dời thời gian đặt lại tiếp theo", "Advanced": "Nâng cao", "Advanced Configuration": "Cấu hình nâng cao", @@ -387,6 +393,7 @@ "API Key (Sandbox)": "Khóa API (Sandbox)", "API Key *": "Khóa API *", "API Key created successfully": "Tạo khóa API thành công", + "API key created. Opening the selected tool...": "Đã tạo API key. Đang mở công cụ đã chọn...", "API Key deleted successfully": "Xóa khóa API thành công", "API Key disabled successfully": "Vô hiệu hóa khóa API thành công", "API Key enabled successfully": "Kích hoạt khóa API thành công", @@ -520,7 +527,9 @@ "Automatically replaces upstream callback URLs with the server address.": "Tự động thay thế URL callback upstream bằng địa chỉ máy chủ.", "Automatically selects the best available group with circuit breaker mechanism": "Tự động chọn nhóm tốt nhất hiện có với cơ chế ngắt mạch", "Automatically sync model list when upstream changes are detected": "Tự động đồng bộ danh sách mô hình khi phát hiện thay đổi từ nguồn", + "Availability": "Khả dụng", "Availability (last 24h)": "Khả dụng (24 giờ qua)", + "Availability Monitor": "Giám sát khả dụng", "Available": "Khả dụng", "Available credits are ordered by soonest expiration.": "Các lượt khả dụng được sắp xếp theo thời điểm hết hạn gần nhất.", "Available disk space": "Dung lượng đĩa khả dụng", @@ -535,6 +544,7 @@ "Average tokens per second sustained per group": "Số token mỗi giây trung bình duy trì cho từng nhóm", "Average TPM": "TPM trung bình", "Average TTFT": "TTFT trung bình", + "Avg latency": "Độ trễ TB", "AWS": "AWS", "AWS Bedrock Claude Compat": "AWS Bedrock Claude tương thích", "AWS Key Format": "Định dạng khóa AWS", @@ -814,6 +824,8 @@ "Choose the default charts, range, and time granularity for model analytics.": "Chọn biểu đồ, khoảng thời gian và độ chi tiết thời gian mặc định cho phân tích mô hình.", "Choose where to fetch upstream metadata.": "Chọn nơi để tìm nạp siêu dữ liệu thượng nguồn.", "Choose which charts are selected by default when opening model analytics.": "Chọn biểu đồ được chọn mặc định khi mở phân tích mô hình.", + "Choose who can see the Availability Monitor entry in the Extensions sidebar.": "Chọn ai thấy mục giám sát trong Extensions.", + "Choose who can see this page in the Extensions sidebar.": "Chọn ai thấy trang này trong Extensions.", "Clamped to": "Giới hạn thành", "Classic (Legacy Frontend)": "Cổ điển (Frontend cũ)", "Claude": "Claude", @@ -949,6 +961,7 @@ "Configuration for Epay payment integration": "Cấu hình cho tích hợp thanh toán Epay", "Configuration for Stripe payment integration": "Cấu hình cho tích hợp thanh toán Stripe", "Configuration required": "Cần cấu hình", + "Configuration tool": "Công cụ cấu hình", "Configure": "Cấu hình", "Configure a Creem product for user recharge options.": "Cấu hình một sản phẩm Creem cho các tùy chọn nạp tiền người dùng.", "Configure a custom ratio for when users use a specific token group.": "Cấu hình tỷ lệ tùy chỉnh khi người dùng sử dụng nhóm token cụ thể.", @@ -974,6 +987,8 @@ "Configure rate limiting rules for a specific user group.": "Cấu hình quy tắc giới hạn tốc độ cho một nhóm người dùng cụ thể.", "Configure routes": "Cấu hình route", "Configure the ratio for this group.": "Cấu hình tỷ lệ cho nhóm này.", + "Configure the sidebar title, icon, embed URL, status, and sort order.": "Cấu hình tiêu đề, biểu tượng, URL nhúng, trạng thái và thứ tự.", + "Configure the sidebar title, icon, URL, open mode, status, and sort order.": "Cấu hình tiêu đề, biểu tượng, URL, cách mở, trạng thái và thứ tự.", "Configure upstream providers and routing.": "Cấu hình nhà cung cấp upstream và định tuyến.", "Configure Waffo Pancake hosted checkout integration for USD-priced top-ups": "Cấu hình tích hợp thanh toán Waffo Pancake (hosted checkout) cho nạp tiền theo USD", "Configure Waffo payment aggregation platform integration": "Cấu hình tích hợp nền tảng tổng hợp thanh toán Waffo", @@ -981,6 +996,7 @@ "Configure your account preferences and integrations": "Cấu hình các tùy chọn và tích hợp tài khoản của bạn", "Configured as PayMethods JSON. The type value decides which payment flow is used: stripe for Stripe, waffo_pancake for Waffo Pancake, and other values are sent to Epay as the type parameter.": "Được lưu dưới dạng JSON PayMethods. Giá trị type quyết định luồng thanh toán sẽ dùng: stripe cho Stripe, waffo_pancake cho Waffo Pancake, các giá trị khác được gửi tới Epay dưới dạng tham số type.", "Configured routes and latency checks": "Tuyến đã cấu hình và kiểm tra độ trễ", + "Configuring...": "Đang cấu hình...", "Confirm": "Xác nhận", "Confirm Action": "Xác nhận hành động", "Confirm and enable": "Xác nhận và bật", @@ -1015,6 +1031,7 @@ "Conflict": "Xung đột", "Connect": "Kết nối", "Connect through OpenAI, Claude, Gemini, and other compatible API routes": "Kết nối qua OpenAI, Claude, Gemini và các tuyến API tương thích khác", + "Connect tool": "Kết nối công cụ", "Connected to io.net service normally.": "Đã kết nối bình thường tới dịch vụ io.net.", "Connection closed": "Kết nối đã đóng", "Connection error": "Lỗi kết nối", @@ -1115,6 +1132,7 @@ "Cost = model price × that one ratio. Nothing else from the group settings enters the formula.": "Chi phí = giá mô hình × đúng một hệ số đó. Không có mục nào khác trong cài đặt nhóm tham gia công thức.", "Cost in USD per request, regardless of tokens used.": "Chi phí bằng USD cho mỗi yêu cầu, bất kể số lượng token được sử dụng.", "Cost Tracking": "Theo dõi chi phí", + "Could not load pricing data. Open the pricing page or refresh and try again.": "Không tải được dữ liệu giá. Mở trang bảng giá hoặc làm mới rồi thử lại.", "Count must be between {{min}} and {{max}}": "Số lượng phải nằm trong khoảng từ {{min}} đến {{max}}.", "Coze": "Coze", "CPU": "CPU", @@ -1126,6 +1144,7 @@ "Create account": "Tạo tài khoản", "Create an account": "Tạo tài khoản", "Create an API key to unlock the real request": "Tạo khóa API để mở yêu cầu thật", + "Create and configure": "Tạo và cấu hình", "Create and review invite or credit codes.": "Tạo và xem xét mã mời hoặc mã tín dụng.", "Create API Key": "Tạo Khóa API", "Create cache": "Tạo bộ nhớ đệm", @@ -1214,6 +1233,12 @@ "Custom multipliers when specific user groups use specific token groups. Example: VIP users get 0.9x rate when using \"edit_this\" group tokens.": "Các hệ số nhân tùy chỉnh khi các nhóm người dùng cụ thể sử dụng các nhóm token cụ thể. Ví dụ: Người dùng VIP được hưởng tỷ lệ 0.9x khi sử dụng các token thuộc nhóm \"edit_this\".", "Custom OAuth": "OAuth tùy chỉnh", "Custom OAuth Providers": "Nhà cung cấp OAuth tùy chỉnh", + "Custom page added. Click \"Save Settings\" to apply.": "Đã thêm trang. Nhấn “Lưu cài đặt” để áp dụng.", + "Custom page deleted. Click \"Save Settings\" to apply.": "Đã xóa trang. Nhấn “Lưu cài đặt” để áp dụng.", + "Custom page not found": "Không tìm thấy trang tùy chỉnh", + "Custom page updated. Click \"Save Settings\" to apply.": "Đã cập nhật trang. Nhấn “Lưu cài đặt” để áp dụng.", + "Custom Pages": "Trang tùy chỉnh", + "Custom pages saved successfully": "Đã lưu trang tùy chỉnh", "Custom Seconds": "Giây tùy chỉnh", "Custom sidebar section": "Phần thanh bên tùy chỉnh", "Custom Time Range": "Khoảng thời gian tùy chỉnh", @@ -1428,7 +1453,9 @@ "Do not wait one second between polling async tasks for this channel": "Không chờ một giây giữa các lần thăm dò tác vụ bất đồng bộ cho kênh này", "Do regex replacement in the target field": "Thực hiện thay thế regex trong trường đích", "Do string replacement in the target field": "Thực hiện thay thế chuỗi trong trường đích", + "Do you want to download the created redemption codes as a text file?": "Bạn có muốn tải xuống các mã đổi thưởng vừa tạo dưới dạng tệp văn bản không?", "Docs": "Tài liệu", + "Documentation": "Tài liệu", "Documentation Link": "Liên kết tài liệu", "Documentation or external knowledge base.": "Tài liệu hoặc cơ sở kiến thức bên ngoài.", "does not exist or might have been removed.": "không tồn tại hoặc có thể đã bị xóa.", @@ -1521,6 +1548,7 @@ "Edit Channel": "Chỉnh sửa Kênh", "Edit channel routing": "Chỉnh sửa định tuyến kênh", "Edit chat preset": "Chỉnh sửa cài đặt trước trò chuyện", + "Edit Custom Page": "Sửa trang tùy chỉnh", "Edit discount tier": "Chỉnh sửa bậc giảm giá", "Edit FAQ": "Chỉnh sửa câu hỏi thường gặp", "Edit group": "Sửa nhóm", @@ -1557,6 +1585,7 @@ "Email Field": "Trường Email", "Email Verification": "Xác minh Email", "Email, summarisation, knowledge work": "Email, tóm tắt, làm việc tri thức", + "Embed in console": "Nhúng trong console", "Embeddings": "Embeddings", "Empty": "Trống", "Empty value will be saved as {}.": "Giá trị trống sẽ được lưu thành {}.", @@ -1564,6 +1593,7 @@ "Enable {{parameter}}": "Bật {{parameter}}", "Enable 2FA": "Bật 2FA", "Enable All": "Bật tất cả", + "Enable availability monitor": "Bật giám sát khả dụng", "Enable check-in feature": "Bật tính năng điểm danh", "Enable Data Dashboard": "Kích hoạt Trang tổng quan Dữ liệu", "Enable demo mode with limited functionality": "Bật chế độ demo với chức năng hạn chế", @@ -1606,6 +1636,7 @@ "Enabled": "Đã bật", "Enabled all channels with tag: {{tag}}": "Đã bật tất cả kênh với nhãn: {{tag}}", "Enabled channels with tag {{tag}}": "Đã kích hoạt các kênh có thẻ {{tag}}", + "Enabled pages with a URL appear under the Extensions group in the console sidebar and open as embedded pages.": "Các trang đã bật và có URL sẽ hiện trong nhóm Mở rộng trên thanh bên và mở dạng nhúng.", "Enabled Status": "Trạng thái kích hoạt", "Enabling...": "Đang bật...", "Encourages introducing new topics": "Khuyến khích chủ đề mới", @@ -1721,6 +1752,7 @@ "Estimated cost": "Chi phí ước tính", "Estimated quota cost": "Ước tính chi phí hạn mức", "Every group name in the pricing table can be used in two places: on a user (the user group, assigned by admins) and on a token (the token group, chosen when creating the token). Same name pool, two different jobs.": "Mỗi tên nhóm trong bảng định giá có thể dùng ở hai nơi: trên người dùng (nhóm người dùng, do quản trị viên gán) và trên token (nhóm token, chọn khi tạo token). Cùng một bộ tên, hai vai trò khác nhau.", + "Everyone": "Tất cả mọi người", "Everything configured for this group, in one place.": "Toàn bộ cấu hình của nhóm này, tại một nơi.", "Exact": "Chính xác", "Exact Match": "Khớp chính xác", @@ -1765,6 +1797,7 @@ "Extend deployment": "Gia hạn triển khai", "Extend failed": "Gia hạn thất bại", "Extended successfully": "Gia hạn thành công", + "Extensions": "Mở rộng", "External Device": "Thiết bị ngoại vi", "External link for users to purchase quota": "Liên kết ngoài để người dùng mua hạn mức", "External operations": "Vận hành bên ngoài", @@ -1843,6 +1876,7 @@ "Failed to initialize system": "Không thể khởi tạo hệ thống", "Failed to load": "Tải thất bại", "Failed to load API keys": "Không thể tải khóa API", + "Failed to load availability": "Tải dữ liệu khả dụng thất bại", "Failed to load billing history": "Không thể tải lịch sử thanh toán", "Failed to load enabled models": "Không thể tải các mô hình đã bật", "Failed to load home page content": "Không thể tải nội dung trang chủ", @@ -1874,6 +1908,7 @@ "Failed to save": "Lưu thất bại", "Failed to save announcements": "Không thể lưu thông báo", "Failed to save API info": "Không thể lưu thông tin API", + "Failed to save custom pages": "Lưu trang tùy chỉnh thất bại", "Failed to save FAQ": "Không thể lưu FAQ", "Failed to save Uptime Kuma groups": "Không thể lưu nhóm Uptime Kuma", "Failed to search API keys": "Không thể tìm kiếm khóa API", @@ -1965,8 +2000,8 @@ "Filter by MjProxy task ID": "Lọc theo ID nhiệm vụ MjProxy", "Filter by model name...": "Lọc theo tên mô hình...", "Filter by model...": "Lọc theo mẫu...", - "Filter by name or ID...": "Lọc theo tên hoặc ID...", "Filter by name, ID, or key...": "Lọc theo tên, ID hoặc khóa...", + "Filter by name, ID, or redemption code...": "Lọc theo tên, ID hoặc mã đổi thưởng...", "Filter by name...": "Lọc theo tên...", "Filter by node": "Lọc theo nút", "Filter by price field": "Lọc theo trường giá", @@ -2216,6 +2251,7 @@ "How It Works": "Cách hoạt động", "How model mapping works": "Cách hoạt động của ánh xạ mô hình", "How much to charge for each US dollar of balance (Epay)": "Tính phí bao nhiêu cho mỗi đô la Mỹ số dư (Epay)", + "How often the Availability Monitor page automatically reloads data. Allowed range: 5–3600 seconds.": "Tần suất trang Giám sát khả dụng tự động tải lại dữ liệu. Phạm vi cho phép: 5–3600 giây.", "How this model name should match requests": "Tên mô hình này nên khớp với các yêu cầu như thế nào", "How to deliver the resulting image": "Cách trả về ảnh kết quả", "How to get an io.net API Key": "Cách lấy Khóa API io.net", @@ -2257,6 +2293,7 @@ "If connecting to upstream One API or New API relay projects, use OpenAI type instead unless you know what you are doing": "Nếu kết nối với dự án relay One API hoặc New API upstream, hãy sử dụng loại OpenAI thay thế trừ khi bạn biết mình đang làm gì", "If default auto group is enabled, newly created tokens start with auto instead of an empty group.": "Nếu bật nhóm auto mặc định, token mới sẽ bắt đầu với auto thay vì nhóm trống.", "If the affinity channel fails and retry succeeds on another channel, update affinity to the successful channel.": "Nếu kênh ưu tiên thất bại và thử lại thành công trên kênh khác, cập nhật ưu tiên sang kênh thành công.", + "If the app did not open, install the tool and use this API key manually:": "Nếu ứng dụng không mở, hãy cài công cụ và dùng API key này để cấu hình thủ công:", "If this keeps happening, please report it on GitHub Issues.": "Nếu sự cố tiếp tục xảy ra, vui lòng báo cáo trên GitHub Issues.", "If you provide generative AI services to the public in mainland China, you will fulfill legal obligations including filing, security assessment, content safety, complaint handling, generated content labeling, log retention, and personal information protection.": "Nếu bạn cung cấp dịch vụ AI tạo sinh cho công chúng tại Trung Quốc đại lục, bạn sẽ thực hiện các nghĩa vụ pháp lý bao gồm đăng ký, đánh giá an toàn, an toàn nội dung, xử lý khiếu nại, gắn nhãn nội dung được tạo, lưu giữ nhật ký và bảo vệ thông tin cá nhân.", "Ignore": "Bỏ qua", @@ -2487,6 +2524,7 @@ "Load template...": "Tải mẫu...", "Loader": "Trình tải", "Loading": "Đang tải", + "Loading available providers...": "Đang tải loại khả dụng…", "Loading channel details": "Đang tải chi tiết kênh", "Loading configuration": "Đang tải cấu hình", "Loading content settings...": "Đang tải cài đặt nội dung...", @@ -2527,6 +2565,7 @@ "Logs": "Nhật ký", "Look for a special ratio rule matching this user group and this billing group. If one exists, use its ratio. Otherwise use the billing group base ratio from the pricing table.": "Tìm quy tắc hệ số đặc biệt khớp với nhóm người dùng và nhóm tính phí này. Nếu có thì dùng hệ số của quy tắc, nếu không thì dùng hệ số cơ bản của nhóm tính phí trong bảng định giá.", "Low balance": "Số dư thấp", + "Lower numbers appear first in the sidebar.": "Số nhỏ hơn sẽ xuất hiện trước trên thanh bên.", "Lowest median first-token latency": "Độ trễ trung vị token đầu tiên thấp nhất", "m": "m", "Maintenance": "Bảo trì", @@ -2771,6 +2810,7 @@ "Multipliers for recharge pricing based on user groups.": "Hệ số nhân cho việc định giá nạp tiền dựa trên nhóm người dùng.", "Must be a valid URL": "Phải là URL hợp lệ", "Must be at least 8 characters": "Phải có ít nhất 8 ký tự", + "Must be http(s). Leave empty to keep the page hidden from the sidebar.": "Phải là http(s). Để trống thì trang sẽ bị ẩn khỏi thanh bên.", "My Subscriptions": "Gói đăng ký của tôi", "my-status": "trạng thái của tôi", "MySQL detected": "Đã phát hiện MySQL", @@ -2841,6 +2881,7 @@ "No available Web chat links": "Không có liên kết Web chat khả dụng", "No backup": "Chưa sao lưu", "No base input price": "Chưa có giá đầu vào cơ bản", + "No billing groups configured.": "Chưa cấu hình nhóm thanh toán.", "No billing records found": "Không tìm thấy hồ sơ thanh toán", "No capabilities reported for this model.": "Chưa có khả năng nào được báo cáo cho mô hình này.", "No Change": "Không thay đổi", @@ -2862,6 +2903,7 @@ "No containers": "Không có container", "No content to copy": "Không có nội dung để sao chép", "No custom OAuth providers configured yet.": "Chưa có nhà cung cấp OAuth tùy chỉnh nào được cấu hình.", + "No custom pages yet. Click \"Add Custom Page\" to create one.": "Chưa có trang tùy chỉnh. Nhấn “Thêm trang tùy chỉnh” để tạo.", "No data": "Không có dữ liệu", "No Data": "Không có dữ liệu", "No data available": "Không có dữ liệu", @@ -2880,6 +2922,7 @@ "No group": "Không có nhóm", "No group found.": "Không tìm thấy nhóm.", "No group-based rate limits configured. Click \"Add group\" to get started.": "Chưa cấu hình giới hạn tốc độ dựa trên nhóm. Nhấp \"Add group\" để bắt đầu.", + "No groups available for this provider type": "Không có nhóm nào cho loại này", "No groups match your search": "Không có nhóm nào khớp với tìm kiếm của bạn", "No groups yet. Add a group to get started.": "Chưa có nhóm nào. Thêm một nhóm để bắt đầu.", "No header overrides configured.": "Không có ghi đè tiêu đề nào được cấu hình.", @@ -2942,9 +2985,13 @@ "No processable upstream model updates for this channel": "Không có cập nhật mô hình upstream có thể xử lý cho kênh này", "No products configured. Click \"Add product\" to get started.": "Chưa cấu hình sản phẩm nào. Nhấp \"Thêm sản phẩm\" để bắt đầu.", "No products match your search": "Không có sản phẩm nào khớp với tìm kiếm của bạn", + "No provider types are available for your current groups.": "Không có loại nào khả dụng cho các nhóm hiện tại của bạn.", + "No provider types are available. Types unlock when pricing models match Anthropic / OpenAI / Gemini / xAI for your groups — having channels alone is not enough.": "Không có loại nào khả dụng. Loại được mở khi mô hình trong bảng giá khớp Anthropic / OpenAI / Gemini / xAI với nhóm của bạn — chỉ có kênh thì chưa đủ.", + "No provider types are available. Types unlock when pricing models match Anthropic / OpenAI for your groups — having channels alone is not enough.": "Không có loại nào khả dụng. Loại được mở khi mô hình trong bảng giá khớp Anthropic / OpenAI với nhóm của bạn — chỉ có kênh thì chưa đủ.", "No providers available": "Không có nhà cung cấp khả dụng", "No Quota": "Không hạn ngạch", "No ratio differences found": "Không tìm thấy sự khác biệt tỷ lệ", + "No recent requests for this group.": "Nhóm này chưa có yêu cầu gần đây.", "No recent usage": "Chưa có sử dụng gần đây", "No records found. Try adjusting your filters.": "Không tìm thấy bản ghi nào. Hãy thử điều chỉnh bộ lọc của bạn.", "No redemption codes available. Create your first redemption code to get started.": "Hiện không có mã đổi thưởng nào. Hãy tạo mã đổi thưởng đầu tiên của bạn để bắt đầu.", @@ -2996,6 +3043,7 @@ "Non-zero invitation rewards require compliance confirmation in Payment Gateway settings.": "Phần thưởng mời khác 0 yêu cầu xác nhận tuân thủ trong cài đặt Cổng thanh toán.", "None": "Không có", "noreply@example.com": "noreply@example.com", + "Normal": "Bình thường", "Normalized:": "Chuẩn hóa:", "Not available": "Không khả dụng", "Not backed up": "Chưa sao lưu", @@ -3016,6 +3064,7 @@ "Notification Email": "Email thông báo", "Notification Method": "Phương thức thông báo", "Notifications": "Thông báo", + "Now": "Hiện tại", "Now a user whose user group is vip creates tokens with different groups and makes one call with each:": "Bây giờ, một người dùng có nhóm người dùng là vip tạo các token với nhóm khác nhau và gọi mỗi token một lần:", "Nucleus sampling probability mass": "Tổng xác suất cho nucleus sampling", "Number of codes to create": "Số mã cần tạo", @@ -3076,6 +3125,7 @@ "Only available for admins. When enabled, you will receive a summary notification via your selected method when the scheduled model check detects upstream model changes or check failures.": "Chỉ khả dụng cho quản trị viên. Khi bật, bạn sẽ nhận được thông báo tổng hợp qua phương thức đã chọn khi kiểm tra mô hình định kỳ phát hiện thay đổi mô hình nguồn hoặc lỗi kiểm tra.", "Only configured combinations are overridden. All other calls keep the billing group base ratio.": "Chỉ các tổ hợp đã cấu hình mới bị ghi đè. Các cuộc gọi khác vẫn dùng hệ số cơ bản của nhóm tính phí.", "Only configured combinations are overridden. All other calls keep the token group base ratio.": "Chỉ các tổ hợp đã cấu hình mới bị ghi đè. Các lệnh gọi khác giữ tỷ lệ cơ bản của nhóm token.", + "Only enabled pages with a URL are shown in the Extensions sidebar group.": "Chỉ các trang đã bật và có URL mới hiện trong nhóm Mở rộng.", "Only enabled parameters are sent with the request.": "Chỉ các tham số đã bật mới được gửi trong yêu cầu.", "Only enter the site origin, for example https://api.example.com. Do not include any path such as /api/user/epay/notify. Leave blank to use the server address.": "Chỉ nhập origin của trang, ví dụ https://api.example.com. Không nhập đường dẫn như /api/user/epay/notify. Để trống để dùng địa chỉ máy chủ.", "Only Mine": "Chỉ của tôi", @@ -3091,9 +3141,10 @@ "Open a source model first": "Mở một mô hình nguồn trước", "Open CC Switch": "Mở công tắc CC", "Open in chat": "Mở trong trò chuyện", - "Open in new tab": "Mở trong tab mới", + "Open in new tab": "Mở tab mới", "Open in New Tab": "Mở trong tab mới", "Open menu": "Mở menu", + "Open mode": "Cách mở", "Open release": "Phát hành mở", "Open source": "Mã nguồn mở", "Open Source": "Mã nguồn mở", @@ -3253,6 +3304,7 @@ "Password reset: {{password}}": "Mật khẩu đã đặt lại: {{password}}", "Passwords do not match": "Mật khẩu không khớp", "Passwords don't match.": "Mật khẩu không khớp.", + "Past": "Trước", "Paste Connection Info": "Dán thông tin kết nối", "Path": "Đường dẫn", "Path not set": "Chưa đặt đường dẫn", @@ -3326,6 +3378,7 @@ "Personal use": "Sử dụng cá nhân", "Personal use mode": "Chế độ sử dụng cá nhân", "Pick a date": "Chọn ngày", + "Pick a provider type, group, model, and client. We create an API key and open the tool for you.": "Chọn loại, nhóm, mô hình và client. Hệ thống sẽ tạo API key và mở công cụ.", "Pick or create both a store and a product before saving.": "Hãy chọn hoặc tạo cả cửa hàng và sản phẩm trước khi lưu.", "Ping Interval (seconds)": "Thời gian Ping (giây)", "Plan": "Gói", @@ -3522,6 +3575,7 @@ "Provider created successfully": "Đã tạo nhà cung cấp thành công", "Provider deleted successfully": "Đã xóa nhà cung cấp thành công", "Provider Name": "Tên Nhà cung cấp", + "Provider type": "Loại nhà cung cấp", "Provider type (OpenAI, Anthropic, etc.)": "Loại nhà cung cấp (OpenAI, Anthropic, v.v.)", "Provider updated successfully": "Nhà cung cấp đã được cập nhật thành công", "Provider-specific endpoint, account, and compatibility settings.": "Thiết lập endpoint, tài khoản và tương thích riêng cho nhà cung cấp.", @@ -3613,6 +3667,7 @@ "Receive Upstream Model Update Notifications": "Nhận thông báo cập nhật mô hình nguồn", "Received": "Đã nhận", "Received amount": "Số tiền đã nhận", + "Recent {{count}} records": "{{count}} bản ghi gần đây", "Recent maintenance tasks running across instances and their execution status.": "Các tác vụ bảo trì gần đây chạy trên các phiên bản và trạng thái thực thi của chúng.", "Recently completed or failed system task runs.": "Các lần chạy tác vụ hệ thống gần đây đã hoàn tất hoặc thất bại.", "Recently launched models": "Các mô hình ra mắt gần đây", @@ -3661,8 +3716,10 @@ "Refresh Cache": "Làm mới bộ nhớ đệm", "Refresh credential": "Làm mới thông tin xác thực", "Refresh details": "Làm mới chi tiết", + "Refresh every {{seconds}}s": "Làm mới mỗi {{seconds}} giây", "Refresh failed": "Làm mới thất bại", "Refresh interval (minutes)": "Khoảng thời gian làm mới (phút)", + "Refresh interval (seconds)": "Khoảng thời gian làm mới (giây)", "Refresh Stats": "Làm mới thống kê", "Refreshing...": "Đang làm mới...", "Refund": "Hoàn tiền", @@ -3748,6 +3805,7 @@ "Request Header Field": "Trường header yêu cầu", "Request Header Override": "Ghi đè header yêu cầu", "Request Header Overrides": "Ghi đè Tiêu đề Yêu cầu", + "Request health by billing group for the latest 100 consume/error logs. Green bars show latency; red bars are failures. Badge uses overall success rate (≥95% normal, ≥80% warning, below 80% abnormal).": "Sức khỏe yêu cầu theo nhóm (100 log gần nhất). Thanh xanh = độ trễ, đỏ = lỗi. Huy hiệu theo tỷ lệ thành công.", "Request ID": "ID yêu cầu", "Request Limits": "Hạn mức yêu cầu", "Request Model": "Mô hình yêu cầu", @@ -3999,6 +4057,7 @@ "Select a color": "Chọn một màu", "Select a group": "Chọn một nhóm", "Select a group type": "Chọn loại nhóm", + "Select a model": "Chọn mô hình", "Select a model to edit pricing": "Chọn mô hình để chỉnh sửa giá", "Select a preset...": "Chọn cấu hình sẵn...", "Select a product": "Chọn sản phẩm", @@ -4013,6 +4072,7 @@ "Select all (filtered)": "Chọn tất cả (đã lọc)", "Select all models": "Chọn tất cả mô hình", "Select All Visible": "Chọn tất cả hiển thị", + "Select an icon": "Chọn biểu tượng", "Select an operation mode and enter the amount": "Chọn chế độ thao tác và nhập số tiền", "Select announcement type": "Select notification type", "Select at least one field to overwrite.": "Chọn ít nhất một trường để ghi đè.", @@ -4046,6 +4106,7 @@ "Select models or add custom ones": "Chọn các mô hình hoặc thêm các mô hình tùy chỉnh", "Select models to process. Unselected \"add\" models will be ignored.": "Chọn các mô hình để xử lý. Các mô hình \"thêm\" không được chọn sẽ bị bỏ qua.", "Select models to run batch tests.": "Chọn mô hình để chạy kiểm thử hàng loạt.", + "Select open mode": "Chọn cách mở", "Select or enter color value": "Chọn hoặc nhập giá trị màu", "Select or enter method identifier": "Chọn hoặc nhập mã định danh phương thức", "Select or enter model name": "Chọn hoặc nhập tên mô hình", @@ -4071,6 +4132,7 @@ "Select theme preset": "Chọn tùy chỉnh chủ đề", "Select time granularity": "Chọn độ chi tiết thời gian", "Select vendor": "Chọn nhà cung cấp", + "Select visibility": "Chọn phạm vi hiển thị", "Selectable groups": "Nhóm có thể chọn", "selected": "đã chọn", "Selected {{count}}": "Đã chọn {{count}}", @@ -4154,6 +4216,8 @@ "Showcase core capabilities with demo credentials and limited access.": "Trình diễn các tính năng cốt lõi với thông tin đăng nhập demo và quyền truy cập hạn chế.", "Showing": "Đang hiển thị", "showing •": "hiển thị •", + "Shown in the console sidebar. Maximum 100 characters.": "Hiển thị trên thanh bên console. Tối đa 100 ký tự.", + "Shows a group-level request heartbeat chart under Extensions. Failed requests require ERROR_LOG_ENABLED.": "Hiển thị biểu đồ heartbeat theo nhóm trong Extensions. Lỗi cần ERROR_LOG_ENABLED.", "Sidebar": "Thanh bên", "Sidebar collapsed by default for new users": "Thanh bên được thu gọn theo mặc định đối với người dùng mới", "Sidebar modules": "Mô-đun thanh bên", @@ -4446,6 +4510,7 @@ "The bound Product powers wallet top-ups: when a user enters any amount, new-api runs the checkout against this single Pancake product and overrides the price per session — no need to pre-create $1 / $5 / $10 SKUs.": "Sản phẩm đã liên kết dùng cho nạp ví: khi người dùng nhập bất kỳ số tiền nào, new-api chạy thanh toán trên một sản phẩm Pancake duy nhất này và ghi đè giá theo từng phiên — không cần tạo trước SKU $1 / $5 / $10.", "The bound Store is the parent container for every Pancake product new-api creates from this admin — both the wallet top-up product and any subscription-plan products. One store is enough; pin a different one only if you genuinely run separate Pancake catalogs.": "Cửa hàng đã liên kết là vùng chứa cha cho mọi sản phẩm Pancake mà new-api tạo từ trang quản trị này — bao gồm sản phẩm nạp ví và mọi sản phẩm gói đăng ký. Một cửa hàng là đủ; chỉ ghim cửa hàng khác nếu bạn thực sự vận hành các catalog Pancake riêng.", "The deployment node that handled the requests": "Nút triển khai đã xử lý các yêu cầu", + "The download will use the redemption name as the filename.": "Tệp tải xuống sẽ dùng tên mã đổi thưởng làm tên tệp.", "The effective domain for Passkey registration. Must match the current domain or be its parent domain.": "Mi", "The entered text does not match the required text.": "Văn bản đã nhập không khớp với văn bản yêu cầu.", "The environment (test vs production) is decided by the key you paste here — use the Test key while integrating, then swap to the Production key when going live.": "Môi trường (test hay production) được quyết định bởi khóa bạn dán tại đây — dùng khóa Test khi tích hợp, sau đó đổi sang khóa Production khi chạy chính thức.", @@ -4458,6 +4523,7 @@ "The name displayed across the application": "Tên hiển thị trên ứng dụng", "The public URL of your server, used for OAuth callbacks, webhooks, and other external integrations": "URL công khai của máy chủ, dùng cho callback OAuth, webhook và các tích hợp bên ngoài khác", "The requested chat preset does not exist or has been removed.": "Cài đặt sẵn cuộc trò chuyện được yêu cầu không tồn tại hoặc đã bị xóa.", + "The requested page does not exist, is disabled, or has no URL configured.": "Trang yêu cầu không tồn tại, đã tắt, hoặc chưa cấu hình URL.", "The reset request stays disabled until a credit is available.": "Yêu cầu đặt lại sẽ bị tắt cho đến khi có lượt khả dụng.", "The setup wizard will use this database during initialization.": "Trình hướng dẫn thiết lập sẽ sử dụng cơ sở dữ liệu này trong quá trình khởi tạo.", "The site is not available at the moment.": "Trang web hiện không khả dụng.", @@ -4496,6 +4562,7 @@ "This channel type requires additional configuration": "Loại kênh này yêu cầu cấu hình bổ sung", "This confirmation unlocks payment, redemption code, subscription plan, and invitation reward features. Please read the statements carefully.": "Xác nhận này mở khóa các tính năng thanh toán, mã đổi thưởng, gói đăng ký và phần thưởng mời. Vui lòng đọc kỹ các tuyên bố.", "This controls model request rate limiting. Web/API route throttling is configured by environment variables and may still return 429.": "Thiết lập này kiểm soát giới hạn tốc độ yêu cầu mô hình. Giới hạn tuyến Web/API được cấu hình bằng biến môi trường và vẫn có thể trả về 429.", + "This custom page will be removed from the list.": "Trang tùy chỉnh này sẽ bị xóa khỏi danh sách.", "This data may be unreliable, use with caution": "Dữ liệu này có thể không đáng tin cậy, sử dụng thận trọng", "This device does not support Passkey": "Thiết bị này không hỗ trợ Passkey", "This device does not support Passkey verification.": "Thiết bị này không hỗ trợ xác minh Passkey.", @@ -4515,6 +4582,7 @@ "This model is not available in any group, or no group pricing information is configured.": "Mô hình này không khả dụng trong bất kỳ nhóm nào, hoặc thông tin giá nhóm chưa được cấu hình.", "This month": "Tháng này", "This page has not been created yet.": "Trang này chưa được tạo.", + "This page opens in a new browser tab because the target site cannot be embedded.": "Trang này mở ở tab mới vì trang đích không thể nhúng.", "This plan does not allow balance redemption": "Gói này không cho phép thanh toán bằng số dư", "This project must be used in compliance with the": "Dự án này phải được sử dụng tuân thủ theo", "This removes {{count}} failed models from this channel. This action cannot be undone.": "Thao tác này sẽ xóa {{count}} mô hình thất bại khỏi kênh này. Không thể hoàn tác.", @@ -4570,6 +4638,7 @@ "times": "lần", "Timing": "Thời gian", "Tip": "Mẹo", + "Title": "Tiêu đề", "to access this resource.": "để truy cập tài nguyên này.", "To Anthropic Messages": "Sang Anthropic Messages", "to confirm": "Chờ xác nhận", @@ -4728,6 +4797,7 @@ "UI granularity only — data is still aggregated hourly": "Chỉ là độ chi tiết UI — dữ liệu vẫn được tổng hợp theo giờ", "Unable to estimate price for this deployment.": "Không thể ước tính giá cho triển khai này.", "Unable to generate chat link. Please contact your administrator.": "Không thể tạo liên kết trò chuyện. Vui lòng liên hệ quản trị viên của bạn.", + "Unable to load availability": "Không tải được dữ liệu khả dụng", "Unable to load groups": "Không thể tải nhóm", "Unable to load rankings": "Không thể tải bảng xếp hạng", "Unable to load rankings data": "Không thể tải dữ liệu bảng xếp hạng", @@ -4858,6 +4928,7 @@ "USD Exchange Rate": "Tỷ giá USD", "USD price per 1M input tokens.": "Giá USD cho mỗi 1 triệu token đầu vào.", "USD price per 1M tokens.": "Giá USD cho mỗi 1 triệu token.", + "Use “Open in new tab” for sites that block iframe embedding (for example Taobao / Xianyu short links).": "Dùng “Mở tab mới” cho các trang chặn iframe (ví dụ liên kết ngắn Taobao / Xianyu).", "Use +: to add a group, -: to remove a default selectable group, or no prefix to append a group.": "Dùng +: để thêm nhóm, -: để xóa nhóm có thể chọn mặc định, hoặc không có tiền tố để nối nhóm.", "Use a compatible browser or device with biometric authentication or a security key to register a Passkey.": "Sử dụng trình duyệt hoặc thiết bị tương thích có xác thực sinh trắc học hoặc khóa bảo mật để đăng ký Khóa truy cập.", "Use a different stable value for each instance, then restart the service.": "Dùng một giá trị ổn định khác nhau cho mỗi phiên bản, sau đó khởi động lại dịch vụ.", @@ -5010,6 +5081,7 @@ "Violation Marker": "Đánh dấu vi phạm", "vip": "vip", "VIP users with premium access": "Người dùng VIP với quyền truy cập cao cấp", + "Visibility": "Phạm vi hiển thị", "Visible": "Hiển thị", "Vision": "Thị giác", "Vision, image / video, document chat": "Thị giác, ảnh / video, hỏi đáp tài liệu", diff --git a/web/default/src/i18n/locales/zh-TW.json b/web/default/src/i18n/locales/zh-TW.json index 111d2def47a6..57b35dcb933c 100644 --- a/web/default/src/i18n/locales/zh-TW.json +++ b/web/default/src/i18n/locales/zh-TW.json @@ -38,6 +38,8 @@ "{{count}} channel(s) enabled": "已啟用 {{count}} 個渠道", "{{count}} channel(s) failed to disable": "{{count}} 個渠道停用失敗", "{{count}} channel(s) failed to enable": "{{count}} 個渠道啟用失敗", + "{{count}} custom pages deleted. Click \"Save Settings\" to apply.": "{{count}} custom pages deleted. Click \"Save Settings\" to apply.", + "{{count}} custom pages will be removed from the list.": "{{count}} custom pages will be removed from the list.", "{{count}} days ago": "{{count}} 日前", "{{count}} days remaining": "剩餘 {{count}} 日", "{{count}} disabled channel(s) deleted": "已刪除 {{count}} 個已停用的渠道", @@ -82,6 +84,7 @@ "+{{count}} more": "還有 {{count}} 項", "| Based on": "| 基於", "0 means data is kept permanently": "0 表示永久保留數據", + "0 means no IP limit.": "0 表示不限制 IP。", "0 means unlimited": "0 表示不限", "1 Day": "1 日", "1 day ago": "1 日前", @@ -118,6 +121,8 @@ "80,443,8080": "80,443,8080", "A billing multiplier. Lower ratios mean lower API call costs.": "收費乘數,倍率越低,API 呼叫費用越低。", "A focused home for keys, balance, routing, and service health.": "集中展示金鑰、餘額、路由和服務健康狀態。", + "A recommended model is selected automatically. You can change it.": "A recommended model is selected automatically. You can change it.", + "Abnormal": "Abnormal", "About": "關於", "About {{days}} days left": "約剩 {{days}} 日", "Accept Unpriced Models": "接受未定價模型", @@ -174,6 +179,7 @@ "Add Condition": "新增條件", "Add credits": "增加額度", "Add custom model \"{{value}}\"": "新增自訂模型「{{value}}」", + "Add Custom Page": "Add Custom Page", "Add discount tier": "新增折扣等級", "Add each model or tag you want to include.": "新增您想包含的每個模型或標籤。", "Add FAQ": "新增問答", @@ -197,6 +203,7 @@ "Add param/header": "新增參數/Header", "Add payment method": "新增支付方式", "Add photos or files": "新增相片或檔案", + "Add prize": "新增獎項", "Add product": "新增產品", "Add Provider": "新增供應商", "Add Quota": "新增配額", @@ -239,6 +246,7 @@ "Administer user accounts and roles.": "管理用戶用戶和角色。", "Administrator account": "管理員用戶", "Administrator username": "管理員用戶名", + "Admins only": "Admins only", "Advance next reset time": "推進下次重置時間", "Advanced": "進階", "Advanced Configuration": "進階設定", @@ -339,7 +347,10 @@ "Allowed": "允許", "Allowed Origins": "允許的 Origins", "Allowed Ports": "允許的端口", + "Allowed: {{min}} – {{max}}": "允許範圍:{{min}} – {{max}}", + "Allowed: ${{min}} – ${{max}}": "允許範圍:${{min}} – ${{max}}", "Already have an account?": "已有用戶?", + "Already spun today": "今日已抽過", "Always matches (default tier).": "始終匹配(預設檔位)。", "Amount": "金額", "Amount cannot be changed when editing.": "編輯時無法更改數量。", @@ -387,6 +398,7 @@ "API Key (Sandbox)": "API 金鑰(沙盒)", "API Key *": "API 金鑰 *", "API Key created successfully": "API 金鑰建立成功", + "API key created. Opening the selected tool...": "API key created. Opening the selected tool...", "API Key deleted successfully": "API 金鑰刪除成功", "API Key disabled successfully": "API 金鑰停用成功", "API Key enabled successfully": "API 金鑰啟用成功", @@ -449,6 +461,10 @@ "Are you sure?": "您確定嗎?", "Area Chart": "面積圖", "Args (space separated)": "參數 (空格分隔)", + "Array of {name, multiplier, weight, is_thanks}. Multiplier must be within [-1, 2].": "陣列項為 {name, multiplier, weight, is_thanks}。倍率須在 [-1, 2] 之間。", + "Array of {name, multiplier, weight, is_thanks}. Multiplier must be within [-1, 2]. Bet amount itself is in USD.": "陣列項為 {name, multiplier, weight, is_thanks}。倍率須在 [-1, 2]。投入金額本身為美元。", + "Array of {name, quota, weight, is_thanks}. Higher quota should use lower weight.": "陣列項為 {name, quota, weight, is_thanks}。額度越高權重應越低。", + "Array of {name, usd, weight, is_thanks}. usd is dollars. Higher usd should use lower weight.": "陣列項為 {name, usd, weight, is_thanks}。usd 為美元金額。金額越高權重應越低。", "Array of chat client presets. Each item is an object with one key-value pair: client name and its URL.": "聊天用戶端預設陣列。每個項目都是一個物件,包含一個鍵值對:用戶端名稱及其 URL。", "Asc": "升序", "Ask anything": "隨便問", @@ -520,7 +536,9 @@ "Automatically replaces upstream callback URLs with the server address.": "自動將上游Callback URL 替換為伺服器地址。", "Automatically selects the best available group with circuit breaker mechanism": "自動選擇可用分組,失敗時觸發熔斷切換", "Automatically sync model list when upstream changes are detected": "偵測到上游模型變更時自動同步模型清單", + "Availability": "Availability", "Availability (last 24h)": "可用率(最近 24 小時)", + "Availability Monitor": "Availability Monitor", "Available": "可用", "Available credits are ordered by soonest expiration.": "可用次數按最早到期排序。", "Available disk space": "可用磁碟空間", @@ -535,6 +553,7 @@ "Average tokens per second sustained per group": "各分組持續輸出的平均每秒 token 數", "Average TPM": "平均 TPM", "Average TTFT": "平均首 Token 延遲", + "Avg latency": "Avg latency", "AWS": "AWS", "AWS Bedrock Claude Compat": "AWS Bedrock Claude 兼容模板", "AWS Key Format": "AWS 金鑰格式", @@ -558,6 +577,7 @@ "Baidu V2": "百度 V2", "Balance": "餘額", "Balance and top-up management": "餘額儲值管理", + "Balance change": "餘額變化", "Balance depleted": "餘額已耗盡", "Balance is shown in quota units": "餘額以額度單位顯示", "Balance queried successfully": "餘額查詢成功", @@ -604,6 +624,15 @@ "Batch upstream model updates applied: {{channels}} channels, {{added}} added, {{removed}} removed, {{fails}} failed": "已大量處理上游模型更新:渠道 {{channels}} 個,加入 {{added}} 個,刪除 {{removed}} 個,失敗 {{fails}} 個", "Best for single-tenant deployments. Pricing and billing options stay hidden.": "適合單用戶部署。定價和收費選項將被隱藏。", "Best TTFT": "最優 TTFT", + "Bet amount": "投入金額", + "Bet amount (USD)": "投入金額(美元)", + "Bet amount is out of range": "投入金額超出允許範圍", + "Bet cannot exceed your current balance": "投入金額不能超過當前餘額", + "Bet cannot exceed your current quota": "投入額度不能超過當前餘額", + "Bet mode prizes": "投入模式獎項", + "Bet prizes JSON": "投入模式獎項 JSON", + "Bet with quota": "投入額度抽獎", + "Bet with USD": "投入美元抽獎", "Billable input tokens": "收費輸入 token", "Billable output tokens": "收費輸出 token", "Billed as default. No cell for this combination, so the base ratio of default applies — the 0.8 of vip plays no part.": "按 default 收費。矩陣中沒有這個組合的格子,因此用 default 的基礎倍率——vip 自己的 0.8 不參與。", @@ -814,6 +843,8 @@ "Choose the default charts, range, and time granularity for model analytics.": "選擇模型呼叫分析的預設圖表、範圍和時間粒度。", "Choose where to fetch upstream metadata.": "選擇從何處獲取上游元數據。", "Choose which charts are selected by default when opening model analytics.": "選擇打開模型呼叫分析時預設選中的圖表。", + "Choose who can see the Availability Monitor entry in the Extensions sidebar.": "Choose who can see the Availability Monitor entry in the Extensions sidebar.", + "Choose who can see this page in the Extensions sidebar.": "Choose who can see this page in the Extensions sidebar.", "Clamped to": "限制為", "Classic (Legacy Frontend)": "經典前端", "Claude": "Claude", @@ -949,6 +980,7 @@ "Configuration for Epay payment integration": "Epay 支付整合的設定", "Configuration for Stripe payment integration": "Stripe 支付整合的設定", "Configuration required": "需要設定", + "Configuration tool": "Configuration tool", "Configure": "設定", "Configure a Creem product for user recharge options.": "為用戶儲值選項設定 Creem 產品。", "Configure a custom ratio for when users use a specific token group.": "設定用戶使用特定令牌分組時的自訂倍率。", @@ -974,6 +1006,8 @@ "Configure rate limiting rules for a specific user group.": "設定特定用戶分組的速率限制規則。", "Configure routes": "設定路由", "Configure the ratio for this group.": "設定此分組的比例。", + "Configure the sidebar title, icon, embed URL, status, and sort order.": "Configure the sidebar title, icon, embed URL, status, and sort order.", + "Configure the sidebar title, icon, URL, open mode, status, and sort order.": "Configure the sidebar title, icon, URL, open mode, status, and sort order.", "Configure upstream providers and routing.": "設定上游提供者和路由。", "Configure Waffo Pancake hosted checkout integration for USD-priced top-ups": "設定 Waffo Pancake 託管結帳,用於美元計價的儲值", "Configure Waffo payment aggregation platform integration": "設定 Waffo 支付聚合平台整合", @@ -981,6 +1015,7 @@ "Configure your account preferences and integrations": "設定您的用戶偏好和整合", "Configured as PayMethods JSON. The type value decides which payment flow is used: stripe for Stripe, waffo_pancake for Waffo Pancake, and other values are sent to Epay as the type parameter.": "儲存為 PayMethods JSON。type 值決定點擊後使用哪個支付流程:stripe 走 Stripe,waffo_pancake 走 Waffo Pancake,其他值作為 Epay 的 type 參數提交。", "Configured routes and latency checks": "已設定路由和延遲檢測", + "Configuring...": "Configuring...", "Confirm": "確認", "Confirm Action": "確認操作", "Confirm and enable": "確認並啟用", @@ -1015,6 +1050,7 @@ "Conflict": "矛盾", "Connect": "連接", "Connect through OpenAI, Claude, Gemini, and other compatible API routes": "透過 OpenAI、Claude、Gemini 以及其他兼容 API 路由接入", + "Connect tool": "Connect tool", "Connected to io.net service normally.": "已正常連接 io.net 服務。", "Connection closed": "連接已關閉", "Connection error": "連接錯誤", @@ -1115,10 +1151,12 @@ "Cost = model price × that one ratio. Nothing else from the group settings enters the formula.": "費用 = 模型價格 × 這一個倍率。分組設定裡的其他項都不參與該公式。", "Cost in USD per request, regardless of tokens used.": "每請求的美元費用,不考慮使用的令牌數。", "Cost Tracking": "成本追蹤", + "Could not load pricing data. Open the pricing page or refresh and try again.": "Could not load pricing data. Open the pricing page or refresh and try again.", "Count must be between {{min}} and {{max}}": "計數必須介於{{min}}和{{max}}之間", "Coze": "Coze", "CPU": "CPU", "CPU Threshold (%)": "CPU 閾值 (%)", + "Crazy Thursday!": "瘋狂星期四!", "Create": "建立", "Create a copy of:": "建立副本:", "Create a key for your app or service": "為你的套用或服務建立金鑰", @@ -1126,6 +1164,7 @@ "Create account": "建立用戶", "Create an account": "建立一個用戶", "Create an API key to unlock the real request": "建立 API 金鑰以解鎖真實請求", + "Create and configure": "Create and configure", "Create and review invite or credit codes.": "建立和審查邀請或信用代碼。", "Create API Key": "建立 API 金鑰", "Create cache": "建立緩存", @@ -1214,6 +1253,12 @@ "Custom multipliers when specific user groups use specific token groups. Example: VIP users get 0.9x rate when using \"edit_this\" group tokens.": "當特定用戶分組使用特定令牌分組時的自訂乘數。示例:VIP 用戶在使用「edit_this」分組令牌時獲得 0.9 倍費率。", "Custom OAuth": "自訂 OAuth", "Custom OAuth Providers": "自訂 OAuth 供應商", + "Custom page added. Click \"Save Settings\" to apply.": "Custom page added. Click \"Save Settings\" to apply.", + "Custom page deleted. Click \"Save Settings\" to apply.": "Custom page deleted. Click \"Save Settings\" to apply.", + "Custom page not found": "Custom page not found", + "Custom page updated. Click \"Save Settings\" to apply.": "Custom page updated. Click \"Save Settings\" to apply.", + "Custom Pages": "Custom Pages", + "Custom pages saved successfully": "Custom pages saved successfully", "Custom Seconds": "自訂秒數", "Custom sidebar section": "自訂側邊欄部分", "Custom Time Range": "自訂時間範圍", @@ -1221,6 +1266,12 @@ "Customize sidebar display content": "個人化設定左側邊欄的顯示內容", "Daily": "每天", "Daily Check-in": "每日簽到", + "Daily prize pool": "每日獎池", + "Daily prize pool (USD)": "每日獎池(美元)", + "Display daily prize pool (USD)": "展示每日獎池(美元)", + "Shown to users on the lottery page. Does not limit real payouts. Doubled on Thursdays. Set 0 to fall back to the actual pool.": "顯示在用戶抽獎頁頂部,不限制真實發獎。週四翻倍。設為 0 則回退為實際獎池限額。", + "Actual daily pool limit (USD)": "實際每日獎池限額(美元)", + "Real daily payout cap used by the backend. Hidden from users. Doubled on Thursdays.": "後端真實發獎上限,不對用戶展示。週四翻倍。", "Daily token usage by model across the past few weeks": "過去幾週內按模型分佈的每日 Token 使用量", "Daily token usage by model across the past month": "過去一個月內各模型的每日 Token 用量", "Daily token usage by model over the past month": "過去一個月內按模型分佈的每日 Token 使用量", @@ -1428,7 +1479,9 @@ "Do not wait one second between polling async tasks for this channel": "該渠道輪詢異步任務時不等待一秒", "Do regex replacement in the target field": "在目標欄位裡做正則替換", "Do string replacement in the target field": "在目標欄位裡做字串替換", + "Do you want to download the created redemption codes as a text file?": "兌換碼建立成功,是否下載兌換碼?", "Docs": "文件", + "Documentation": "Documentation", "Documentation Link": "文件連結", "Documentation or external knowledge base.": "文件或外部知識庫。", "does not exist or might have been removed.": "不存在或可能已被移除。", @@ -1439,6 +1492,7 @@ "Doubao custom API address editing unlocked": "已解鎖豆包自訂 API 地址編輯", "DoubaoVideo": "DoubaoVideo", "Double check the configuration below. Your system will be locked until initialization is complete.": "仔細檢查以下設定。您的系統將在初始化完成前保持鎖定狀態。", + "Doubled automatically on Thursdays.": "週四自動翻倍。", "Downgrade Group": "降級分組", "Downgrade to pre-purchase group": "降級到購買前分組", "Downgrade to this group after the subscription expires": "訂閱過期後降級到該分組", @@ -1507,6 +1561,7 @@ "Each item must have exactly one key-value pair.": "每個條目必須恰好包含一個鍵值對。", "Each line represents one keyword. Leave blank to disable the list but keep the switch states.": "每行代表一個關鍵詞。留空以停用清單,但保留開關狀態。", "Each matrix cell is one rule: users of this row group pay this ratio when billed as this column group. In JSON the row is the outer key and the column is the inner key.": "矩陣的每個單元格是一條規則:該行用戶分組的用戶按該列分組收費時使用此倍率。在 JSON 中行是外層鍵,列是內層鍵。", + "Each row is a prize. Higher USD should usually have lower weight.": "每一行是一個獎項。金額越高,權重通常應越低。", "Each rule reads as a sentence: users of one group pay a special ratio when billed as another group. Without a rule, the billing group base ratio applies.": "每條規則就是一句話:某分組的用戶按另一分組收費時享受特殊倍率。沒有規則時,使用收費分組的基礎倍率。", "Each tier supports 0~2 conditions (over len, p, c); the last tier is the catch-all without conditions. Use len (full input length, including cache hits) for tier conditions to avoid mis-routing when cache hits reduce p.": "每個檔位支援 0~2 個條件(針對 len、p、c),最後一檔為兜底檔無需條件。建議條件使用 len(完整輸入長度,含緩存命中),避免緩存命中降低 p 導致檔位誤判。", "Each tier supports up to 2 conditions; the last tier is the catch-all without conditions. Use full input length for tier conditions to avoid mis-routing when cache hits reduce billable input tokens.": "每個檔位最多支援 2 個條件;最後一個檔位是不帶條件的兜底檔。建議使用完整輸入長度作為檔位條件,避免緩存命中減少收費輸入 token 後誤判檔位。", @@ -1521,6 +1576,7 @@ "Edit Channel": "編輯渠道", "Edit channel routing": "編輯渠道路由", "Edit chat preset": "編輯聊天預設", + "Edit Custom Page": "Edit Custom Page", "Edit discount tier": "編輯折扣檔位", "Edit FAQ": "編輯常見問題", "Edit group": "編輯分組", @@ -1557,6 +1613,7 @@ "Email Field": "電郵欄位", "Email Verification": "電郵驗證", "Email, summarisation, knowledge work": "郵件、摘要與知識工作", + "Embed in console": "Embed in console", "Embeddings": "嵌入", "Empty": "空", "Empty value will be saved as {}.": "空值將儲存為 {}。", @@ -1564,6 +1621,7 @@ "Enable {{parameter}}": "啟用 {{parameter}}", "Enable 2FA": "啟用 2FA", "Enable All": "啟用全部", + "Enable availability monitor": "Enable availability monitor", "Enable check-in feature": "啟用簽到功能", "Enable Data Dashboard": "啟用數據儀表板", "Enable demo mode with limited functionality": "啟用功能受限的演示模式", @@ -1578,6 +1636,7 @@ "Enable io.net deployments": "啟用 io.net 部署", "Enable io.net model deployment service in console": "在控制台啟用 io.net 模型部署服務", "Enable LinuxDO OAuth": "啟用 LinuxDO OAuth", + "Enable lucky slot": "啟用幸運老虎機", "Enable model performance metrics": "啟用模型效能指標", "Enable OIDC": "啟用 OIDC", "Enable or disable this channel": "啟用或停用此渠道", @@ -1606,6 +1665,7 @@ "Enabled": "已啟用", "Enabled all channels with tag: {{tag}}": "已啟用標籤「{{tag}}」下的所有渠道", "Enabled channels with tag {{tag}}": "啟用標籤為 {{tag}} 的渠道", + "Enabled pages with a URL appear under the Extensions group in the console sidebar and open as embedded pages.": "Enabled pages with a URL appear under the Extensions group in the console sidebar and open as embedded pages.", "Enabled Status": "啟用狀態", "Enabling...": "正在啟用...", "Encourages introducing new topics": "鼓勵引入新話題", @@ -1721,6 +1781,7 @@ "Estimated cost": "預計成本", "Estimated quota cost": "估算配額費用", "Every group name in the pricing table can be used in two places: on a user (the user group, assigned by admins) and on a token (the token group, chosen when creating the token). Same name pool, two different jobs.": "定價表中的每個分組名可用在兩個地方:用戶身上(用戶分組,由管理員分配)和令牌身上(令牌分組,建立令牌時選擇)。同一批名字,兩種不同職責。", + "Everyone": "Everyone", "Everything configured for this group, in one place.": "該分組的全部設定,一處看全。", "Exact": "精確", "Exact Match": "完全匹配", @@ -1765,6 +1826,7 @@ "Extend deployment": "延長部署", "Extend failed": "延長失敗", "Extended successfully": "延長成功", + "Extensions": "Extensions", "External Device": "外部設備", "External link for users to purchase quota": "供用戶購買配額的外部連結", "External operations": "對外運營", @@ -1843,12 +1905,14 @@ "Failed to initialize system": "系統初始化失敗", "Failed to load": "載入失敗", "Failed to load API keys": "載入 API 金鑰失敗", + "Failed to load availability": "Failed to load availability", "Failed to load billing history": "載入收費歷史失敗", "Failed to load enabled models": "獲取啟用模型失敗", "Failed to load home page content": "載入首頁內容失敗", "Failed to load image": "無法載入圖像", "Failed to load key status": "載入金鑰狀態失敗", "Failed to load logs": "載入日誌失敗", + "Failed to load lottery status": "取得抽獎狀態失敗", "Failed to load Passkey status": "載入 Passkey 狀態失敗", "Failed to load playground groups": "載入 playground 分組失敗", "Failed to load playground models": "載入 playground 模型失敗", @@ -1874,7 +1938,9 @@ "Failed to save": "儲存失敗", "Failed to save announcements": "儲存公告失敗", "Failed to save API info": "儲存 API 資訊失敗", + "Failed to save custom pages": "Failed to save custom pages", "Failed to save FAQ": "儲存 FAQ 失敗", + "Failed to save settings": "儲存設定失敗", "Failed to save Uptime Kuma groups": "儲存 Uptime Kuma 組失敗", "Failed to search API keys": "搜尋 API 金鑰失敗", "Failed to search redemption codes": "搜尋兌換碼失敗", @@ -1965,8 +2031,8 @@ "Filter by MjProxy task ID": "按 MjProxy 任務 ID 篩選", "Filter by model name...": "按模型名稱篩選...", "Filter by model...": "按模型篩選...", - "Filter by name or ID...": "按名稱或 ID 篩選...", "Filter by name, ID, or key...": "按名稱、ID 或金鑰篩選...", + "Filter by name, ID, or redemption code...": "按名稱、ID 或兌換碼篩選...", "Filter by name...": "按名稱篩選...", "Filter by node": "按節點篩選", "Filter by price field": "按價格欄位篩選", @@ -2024,7 +2090,7 @@ "footer.columns.related.links.oneApi": "One API", "footer.columns.related.title": "相關項目", "footer.defaultCopyright": "版權所有。", - "footer.new\u0061pi.projectAttributionSuffix": "版權所有,由項目貢獻者設計與開發。", + "footer.newapi.projectAttributionSuffix": "版權所有,由項目貢獻者設計與開發。", "For channels added after May 10, 2025, no need to remove \".\" from model names during deployment": "對於 2025 年 5 月 10 日之後新增的渠道,在部署時無需從模型名稱中移除 \".\"", "For private deployments, format: https://fastgpt.run/api/openapi": "對於私有部署,格式為:https://fastgpt.run/api/openapi", "Force a syntactically valid JSON response": "強制返回語法合法的 JSON", @@ -2051,6 +2117,9 @@ "Forward requests directly to upstream providers without any post-processing.": "將請求直接轉發給上游供應商,不進行任何後處理。", "Frames per second": "幀率", "Free": "可用", + "Free mode prizes": "免費模式獎項", + "Free prizes JSON": "免費模式獎項 JSON", + "Free prizes JSON (USD)": "免費模式獎項 JSON(美元)", "Free: {{free}} / Total: {{total}}": "可用空間: {{free}} / 總空間: {{total}}", "Frequency Penalty": "頻率懲罰", "Friendly name to identify this channel": "用於識別此渠道的友好名稱", @@ -2112,6 +2181,8 @@ "Go to settings": "前往設定", "Go to Settings": "前往設定", "Good": "良好", + "Got it": "知道了", + "Congratulations!": "恭喜中獎!", "Gotify Application Token": "Gotify 套用令牌", "Gotify Documentation": "Gotify 文件", "Gotify Server URL": "Gotify 伺服器 URL", @@ -2216,6 +2287,7 @@ "How It Works": "工作流程", "How model mapping works": "模型映射如何運作", "How much to charge for each US dollar of balance (Epay)": "每美元餘額(Epay)的收費金額", + "How often the Availability Monitor page automatically reloads data. Allowed range: 5–3600 seconds.": "可用性監控頁面自動重新載入資料的間隔。允許範圍:5–3600 秒。", "How this model name should match requests": "此模型名稱應如何匹配請求", "How to deliver the resulting image": "圖像結果的返回方式", "How to get an io.net API Key": "如何獲取 io.net API 金鑰", @@ -2257,6 +2329,7 @@ "If connecting to upstream One API or New API relay projects, use OpenAI type instead unless you know what you are doing": "如果連接上游 One API 或 New API 中繼項目,除非您知道自己在做什麼,否則請使用 OpenAI 類型", "If default auto group is enabled, newly created tokens start with auto instead of an empty group.": "如果啟用預設 auto 分組,新建令牌會預設使用 auto,而不是空分組。", "If the affinity channel fails and retry succeeds on another channel, update affinity to the successful channel.": "如果親和到的渠道失敗,重試到其他渠道成功後,將親和更新到成功的渠道。", + "If the app did not open, install the tool and use this API key manually:": "If the app did not open, install the tool and use this API key manually:", "If this keeps happening, please report it on GitHub Issues.": "如果問題持續出現,請到 GitHub Issues 反饋。", "If you provide generative AI services to the public in mainland China, you will fulfill legal obligations including filing, security assessment, content safety, complaint handling, generated content labeling, log retention, and personal information protection.": "如果你在中國大陸向公眾提供生成式人工智能服務,你將履行備案、安全評估、內容安全、投訴處理、生成內容標識、日誌留存和個人資訊保護等法律義務。", "Ignore": "忽略", @@ -2487,6 +2560,7 @@ "Load template...": "載入模板...", "Loader": "載入器", "Loading": "載入中", + "Loading available providers...": "Loading available providers...", "Loading channel details": "正在載入渠道詳情", "Loading configuration": "正在載入設定", "Loading content settings...": "正在載入內容設定...", @@ -2526,8 +2600,13 @@ "Logo URL": "徽標 URL", "Logs": "日誌", "Look for a special ratio rule matching this user group and this billing group. If one exists, use its ratio. Otherwise use the billing group base ratio from the pricing table.": "查找匹配「該用戶分組 + 該收費分組」的特殊倍率規則。有就用規則裡的倍率,沒有就用定價分組表中收費分組的基礎倍率。", + "Lottery draw failed": "抽獎失敗", "Low balance": "餘額偏低", + "Lower numbers appear first in the sidebar.": "Lower numbers appear first in the sidebar.", "Lowest median first-token latency": "最低首 token 延遲中位數", + "Lucky Slot": "幸運老虎機", + "Lucky Slot Lottery": "幸運老虎機抽獎", + "Lucky Slot Machine": "幸運老虎機", "m": "分鐘", "Maintenance": "維護", "Make extra groups visible to, or hide default groups from, users of a specific group.": "讓特定分組的用戶額外看到某些分組,或對其屏蔽預設可選的分組。", @@ -2572,7 +2651,10 @@ "Matched Tier": "命中階梯", "Matches models not claimed by earlier splits.": "匹配前面分流未佔用的模型。", "Matching Rules": "匹配規則", + "Max bet": "最大投入", + "Max bet (USD)": "最大投入(美元)", "Max Disk Cache Size (MB)": "磁碟緩存最大總量 (MB)", + "Max draws per IP / day": "同 IP 每日最多抽獎次數", "Max Entries": "最大條目數", "Max output": "最大輸出", "Max Requests (incl. failures)": "最大請求數(包括失敗)", @@ -2607,6 +2689,13 @@ "Merge into Other": "合併為其他", "Message Priority": "訊息優先級", "Metadata": "元資訊", + "Min bet": "最小投入", + "Min bet (USD)": "最小投入(美元)", + "Min bet cannot exceed max bet": "最小投入不能大於最大投入", + "Require redemption code to play": "參與需先兌換過兌換碼", + "Redeem code required": "需要先兌換兌換碼", + "Please redeem a code before playing. Crazy Thursday does not require this.": "請先使用兌換碼儲值後再參與抽獎。瘋狂星期四無需兌換。", + "On normal days, users must have redeemed at least one code. Crazy Thursday skips this requirement.": "平時需至少成功兌換過一次兌換碼才可參與;瘋狂星期四不限制。", "min downtime": "分鐘停機", "Min Top-up": "最低儲值", "Min Top-up:": "最低儲值:", @@ -2768,9 +2857,11 @@ "Multiplier for completion tokens.": "補全令牌的倍數。", "Multiplier for image processing.": "圖像處理的倍率。", "Multiplier for prompt tokens.": "提示令牌的倍數。", + "Multiplier is relative to bet amount. Range: -1 to 2.": "倍率為相對投入金額。範圍:-1 到 2。", "Multipliers for recharge pricing based on user groups.": "基於用戶分組的儲值定價倍率。", "Must be a valid URL": "必須是有效的 URL", "Must be at least 8 characters": "必須至少 8 個字元", + "Must be http(s). Leave empty to keep the page hidden from the sidebar.": "Must be http(s). Leave empty to keep the page hidden from the sidebar.", "My Subscriptions": "我的訂閱", "my-status": "我的狀態", "MySQL detected": "偵測到 MySQL", @@ -2812,6 +2903,7 @@ "New password": "新密碼", "New Password": "新密碼", "New password must be different from current password": "新密碼必須與目前密碼不同", + "New prize": "新獎項", "New User Quota": "新用戶配額", "New version available: {{version}}": "有新版本可用:{{version}}", "NewAPI": "NewAPI", @@ -2841,6 +2933,7 @@ "No available Web chat links": "沒有可用的 Web 聊天連結", "No backup": "無備份", "No base input price": "未設定基礎輸入價格", + "No billing groups configured.": "No billing groups configured.", "No billing records found": "未找到賬單記錄", "No capabilities reported for this model.": "該模型暫未報告任何能力。", "No Change": "無變化", @@ -2862,6 +2955,7 @@ "No containers": "無容器", "No content to copy": "沒有可複製的內容", "No custom OAuth providers configured yet.": "尚未設定自訂 OAuth 供應商。", + "No custom pages yet. Click \"Add Custom Page\" to create one.": "No custom pages yet. Click \"Add Custom Page\" to create one.", "No data": "暫無數據", "No Data": "無數據", "No data available": "暫無數據", @@ -2880,6 +2974,7 @@ "No group": "未設定", "No group found.": "未找到分組。", "No group-based rate limits configured. Click \"Add group\" to get started.": "未設定基於組的速率限制。點擊「新增組」開始使用。", + "No groups available for this provider type": "No groups available for this provider type", "No groups match your search": "沒有組匹配您的搜尋", "No groups yet. Add a group to get started.": "暫無分組,新增一個分組開始設定。", "No header overrides configured.": "未設定標頭覆蓋。", @@ -2942,9 +3037,13 @@ "No processable upstream model updates for this channel": "該渠道暫無可處理的上游模型更新", "No products configured. Click \"Add product\" to get started.": "未設定產品。點擊「新增產品」開始。", "No products match your search": "沒有產品匹配您的搜尋", + "No provider types are available for your current groups.": "No provider types are available for your current groups.", + "No provider types are available. Types unlock when pricing models match Anthropic / OpenAI / Gemini / xAI for your groups — having channels alone is not enough.": "No provider types are available. Types unlock when pricing models match Anthropic / OpenAI / Gemini / xAI for your groups — having channels alone is not enough.", + "No provider types are available. Types unlock when pricing models match Anthropic / OpenAI for your groups — having channels alone is not enough.": "No provider types are available. Types unlock when pricing models match Anthropic / OpenAI for your groups — having channels alone is not enough.", "No providers available": "暫無可用供應商", "No Quota": "無餘額", "No ratio differences found": "未發現比率差異", + "No recent requests for this group.": "No recent requests for this group.", "No recent usage": "暫無使用記錄", "No records found. Try adjusting your filters.": "未找到記錄。嘗試調整您的篩選條件。", "No redemption codes available. Create your first redemption code to get started.": "沒有可用的兌換碼。建立您的第一個兌換碼即可開始使用。", @@ -2996,6 +3095,7 @@ "Non-zero invitation rewards require compliance confirmation in Payment Gateway settings.": "非零邀請獎勵需要先在支付閘道設定中確認合規條款。", "None": "無", "noreply@example.com": "noreply@example.com", + "Normal": "Normal", "Normalized:": "已歸一化:", "Not available": "不可用", "Not backed up": "未備份", @@ -3016,6 +3116,7 @@ "Notification Email": "通知電郵", "Notification Method": "通知方式", "Notifications": "通知", + "Now": "Now", "Now a user whose user group is vip creates tokens with different groups and makes one call with each:": "現在,一個用戶分組為 vip 的用戶建立了不同分組的令牌,各呼叫一次:", "Nucleus sampling probability mass": "核採樣累積概率", "Number of codes to create": "要建立的代碼數量", @@ -3076,6 +3177,7 @@ "Only available for admins. When enabled, you will receive a summary notification via your selected method when the scheduled model check detects upstream model changes or check failures.": "僅管理員可用。啟用後,當定時模型檢查偵測到上游模型變更或檢查失敗時,您將透過所選方式收到摘要通知。", "Only configured combinations are overridden. All other calls keep the billing group base ratio.": "只有設定過的組合才會被覆蓋,其餘呼叫仍使用收費分組的基礎倍率。", "Only configured combinations are overridden. All other calls keep the token group base ratio.": "只有已設定的組合會被覆蓋,其他呼叫仍使用令牌分組的基礎倍率。", + "Only enabled pages with a URL are shown in the Extensions sidebar group.": "Only enabled pages with a URL are shown in the Extensions sidebar group.", "Only enabled parameters are sent with the request.": "只有啟用的參數會隨請求傳送。", "Only enter the site origin, for example https://api.example.com. Do not include any path such as /api/user/epay/notify. Leave blank to use the server address.": "只填寫站點根域名,例如 https://api.example.com。不要填寫 /api/user/epay/notify 這類路徑。留空則使用伺服器地址。", "Only Mine": "僅自己", @@ -3083,6 +3185,7 @@ "Only one OpenAI Models route is allowed": "僅允許設定一條 OpenAI 模型路由", "Only selected fields will be overwritten. You can re-run the sync wizard if new conflicts appear.": "僅選定的欄位將會被覆蓋。如果出現新的衝突,您可以重新執行同步精靈。", "Only successful requests": "僅成功的請求", + "Only recharged users can play. Your quota upper limit must be greater than ${{amount}}.": "僅儲值用戶可參與抽獎。你的額度上限需大於 ${{amount}}。", "Only successful requests count toward this limit.": "僅成功的請求計入此限制。", "Only the last {{value}} log files will be retained; the rest will be deleted.": "將只保留最近 {{value}} 個日誌檔案,其餘將被刪除。", "Oops! Page Not Found!": "糟糕!頁面未找到!", @@ -3094,6 +3197,7 @@ "Open in new tab": "在新標籤頁中打開", "Open in New Tab": "在新標籤頁中打開", "Open menu": "打開選單", + "Open mode": "Open mode", "Open release": "打開版本", "Open source": "開源", "Open Source": "開源項目", @@ -3142,6 +3246,8 @@ "Optional settings for advanced container configuration.": "進階容器設定的可選設定。", "Optional supplementary information (max 100 characters)": "可選補充資訊 (最多 100 個字元)", "Optional tag for grouping channels": "用於分組渠道的可選標籤", + "Optional. Max net win is 2x bet; you may also lose balance.": "可選。最大淨收益為投入的 2 倍,也可能扣減餘額。", + "Optional. Max net win is 2x bet; you may also lose quota.": "可選。最大淨收益為投入的 2 倍,也可能扣減額度。", "Opus Model": "Opus 模型", "Or continue with": "或繼續使用", "Or enter this key manually:": "或手動輸入此金鑰:", @@ -3253,6 +3359,7 @@ "Password reset: {{password}}": "密碼已重置:{{password}}", "Passwords do not match": "密碼不匹配", "Passwords don't match.": "兩次輸入的密碼不一致。", + "Past": "Past", "Paste Connection Info": "貼上連線資訊", "Path": "路徑", "Path not set": "未設定路徑", @@ -3326,8 +3433,11 @@ "Personal use": "個人使用", "Personal use mode": "個人使用模式", "Pick a date": "選擇日期", + "Pick a provider type, group, model, and client. We create an API key and open the tool for you.": "Pick a provider type, group, model, and client. We create an API key and open the tool for you.", "Pick or create both a store and a product before saving.": "儲存前請同時選擇或建立店鋪和產品。", "Ping Interval (seconds)": "Ping 間隔(秒)", + "Pity prize triggered": "已觸發保底獎項", + "Pity progress": "保底進度", "Plan": "套餐", "Plan Name": "套餐名稱", "Plan Price": "套餐價格", @@ -3342,6 +3452,7 @@ "Playground and chat functions": "操練場和聊天功能", "Playground experiments and live conversations.": "Playground 實驗和實時對話。", "Please agree to the legal terms first": "請先同意法律條款", + "Please complete the human verification first": "請先完成人機驗證", "Please complete the security check to continue.": "請完成安全驗證以繼續。", "Please confirm that you understand the consequences": "請確認您了解後果", "Please confirm your password": "請確認密碼", @@ -3486,6 +3597,8 @@ "Priority order for tokens in the auto group. The system tries groups from top to bottom.": "auto 分組令牌的優先順序。系統會從上到下依次嘗試各分組。", "Privacy Policy": "私隱政策", "Private Deployment URL": "私有部署 URL", + "Prize JSON is invalid": "獎項 JSON 無效", + "Prize pool and free prize amounts are doubled today. V me 50!": "今日獎池與免費獎項金額均已翻倍。V 我 50!", "Processing OAuth response...": "正在處理 OAuth 回應...", "Processing...": "處理中...", "Product": "產品", @@ -3511,6 +3624,11 @@ "Prompt price ($/1M tokens)": "提示詞價格(美元/100 萬 token)", "Proprietary": "商業閉源", "Protect login and registration with Cloudflare Turnstile": "使用 Cloudflare Turnstile 保護登入和註冊", + "Protect login, registration and lottery draws with Cloudflare Turnstile": "使用 Cloudflare Turnstile 保護登入、註冊和抽獎", + "Unable to enable Turnstile. Please fill in the Turnstile site key first.": "無法啟用 Turnstile,請先填寫站點密鑰(Site Key)。", + "Public site key from Cloudflare Turnstile. Required for the widget to render.": "Cloudflare Turnstile 的公開站點密鑰,用於頁面渲染驗證元件。", + "If already saved, this field shows ********. Leave it unchanged unless you need to replace the secret.": "若已儲存會顯示 ********,無需改動;只有要更換密鑰時才重新填寫。", + "No changes to save": "沒有需要儲存的變更", "Provide a JSON object where each key maps to an endpoint definition.": "提供一個 JSON 物件,其中每個鍵映射到一個端點定義。", "Provide a valid URL starting with http:// or https://": "請提供以 http:// 或 https:// 開頭的有效 URL", "Provide Markdown, HTML, or an external URL for the privacy policy": "提供 Markdown、HTML 或外部 URL 作為私隱政策", @@ -3522,6 +3640,7 @@ "Provider created successfully": "供應商建立成功", "Provider deleted successfully": "供應商刪除成功", "Provider Name": "供應商名稱", + "Provider type": "Provider type", "Provider type (OpenAI, Anthropic, etc.)": "供應商類型 (OpenAI、Anthropic 等)", "Provider updated successfully": "供應商更新成功", "Provider-specific endpoint, account, and compatibility settings.": "設定供應商專屬的端點、用戶和兼容性選項。", @@ -3537,6 +3656,7 @@ "Published:": "已發佈:", "Pull": "拉取", "Pull model": "拉取模型", + "Pull to spin": "拉動拉桿抽獎", "Pulling...": "拉取中...", "Purchase Limit": "限購", "Purchase limit reached": "已達到購買上限", @@ -3559,6 +3679,7 @@ "Quota": "額度", "Quota ({{currency}})": "額度 ({{currency}})", "Quota adjusted successfully": "調整額度成功", + "Quota change": "額度變化", "Quota clamped": "額度已限制", "Quota consumed before charging users": "向用戶收費前消耗的配額", "Quota Distribution": "消耗分佈", @@ -3613,6 +3734,7 @@ "Receive Upstream Model Update Notifications": "接收上游模型更新通知", "Received": "獲得", "Received amount": "已收額度", + "Recent {{count}} records": "Recent {{count}} records", "Recent maintenance tasks running across instances and their execution status.": "跨實例執行的近期維護任務及其執行狀態。", "Recently completed or failed system task runs.": "最近已完成或失敗的系統任務執行記錄。", "Recently launched models": "近期發佈的模型", @@ -3620,6 +3742,7 @@ "Recharge": "儲值", "Recharge Amount": "儲值金額", "Recharge Amount (USD)": "儲值金額 (USD)", + "Recharge required": "需要儲值", "Recommended": "推薦", "Recommended actions": "推薦操作", "Recommended to keep this high to avoid upstream throttling.": "建議保持此值較高,以避免上游限流。", @@ -3661,8 +3784,10 @@ "Refresh Cache": "重新整理緩存", "Refresh credential": "重新整理憑證", "Refresh details": "重新整理詳情", + "Refresh every {{seconds}}s": "Refresh every {{seconds}}s", "Refresh failed": "重新整理失敗", "Refresh interval (minutes)": "重新整理間隔 (分鐘)", + "Refresh interval (seconds)": "重新整理間隔(秒)", "Refresh Stats": "重新整理統計", "Refreshing...": "重新整理中...", "Refund": "退款", @@ -3685,6 +3810,7 @@ "Relying Party Display Name": "依賴方顯示名稱", "Relying Party ID": "依賴方 ID", "Remaining": "剩餘", + "Remaining pool": "剩餘獎池", "Remaining quota": "剩餘配額", "Remaining Quota ({{currency}})": "剩餘額度 ({{currency}})", "Remaining quota units": "剩餘配額單位", @@ -3704,6 +3830,7 @@ "Remove node filter": "移除節點篩選", "Remove Passkey": "解綁 Passkey", "Remove Passkey?": "移除通行金鑰?", + "Remove prize": "刪除獎項", "Remove rule group": "移除規則組", "Remove string prefix": "去掉字串前綴", "Remove string suffix": "去掉字串後綴", @@ -3748,6 +3875,7 @@ "Request Header Field": "請求頭欄位", "Request Header Override": "請求頭覆蓋", "Request Header Overrides": "請求頭覆蓋", + "Request health by billing group for the latest 100 consume/error logs. Green bars show latency; red bars are failures. Badge uses overall success rate (≥95% normal, ≥80% warning, below 80% abnormal).": "Request health by billing group for the latest 100 consume/error logs. Green bars show latency; red bars are failures. Badge uses overall success rate (≥95% normal, ≥80% warning, below 80% abnormal).", "Request ID": "請求 ID", "Request Limits": "請求限制", "Request Model": "請求模型", @@ -3999,6 +4127,7 @@ "Select a color": "選擇顏色", "Select a group": "選擇一個分組", "Select a group type": "選擇分組類型", + "Select a model": "Select a model", "Select a model to edit pricing": "選擇一個模型編輯定價", "Select a preset...": "選擇一個預設...", "Select a product": "選擇產品", @@ -4013,6 +4142,7 @@ "Select all (filtered)": "全選(篩選結果)", "Select all models": "選擇所有模型", "Select All Visible": "全選目前", + "Select an icon": "Select an icon", "Select an operation mode and enter the amount": "選擇操作模式並輸入金額", "Select announcement type": "選擇公告類型", "Select at least one field to overwrite.": "請選擇至少一個要覆蓋的欄位。", @@ -4046,6 +4176,7 @@ "Select models or add custom ones": "選擇模型或新增自訂模型", "Select models to process. Unselected \"add\" models will be ignored.": "勾選要處理的模型,未勾選的「新增」模型將作為忽略處理。", "Select models to run batch tests.": "選擇要執行大量測試的模型。", + "Select open mode": "Select open mode", "Select or enter color value": "選擇或輸入顏色值", "Select or enter method identifier": "選擇或輸入支付方式標識", "Select or enter model name": "選擇或輸入模型名稱", @@ -4071,6 +4202,7 @@ "Select theme preset": "選擇主題預設", "Select time granularity": "選擇時間粒度", "Select vendor": "選擇供應商", + "Select visibility": "Select visibility", "Selectable groups": "可選分組", "selected": "已選擇", "Selected {{count}}": "已選 {{count}} 個", @@ -4127,6 +4259,7 @@ "Setting updated successfully": "設定更新成功", "Settings": "設定", "Settings & Preferences": "設定與偏好", + "Settings saved": "設定已儲存", "Settings updated successfully": "設定更新成功", "Setup guide": "設定引導", "Setup guide complete": "設定引導已完成", @@ -4154,6 +4287,9 @@ "Showcase core capabilities with demo credentials and limited access.": "使用演示憑證和有限存取權限展示核心功能。", "Showing": "顯示第", "showing •": "顯示 •", + "Shown in the console sidebar. Maximum 100 characters.": "Shown in the console sidebar. Maximum 100 characters.", + "Shows a group-level request heartbeat chart under Extensions. Failed requests require ERROR_LOG_ENABLED.": "Shows a group-level request heartbeat chart under Extensions. Failed requests require ERROR_LOG_ENABLED.", + "Shows Lucky Slot under Extensions. Draws are decided by the backend once per user per day.": "在拓展選單顯示幸運老虎機。結果由後端裁決,每用戶每天僅可抽一次。", "Sidebar": "側邊欄", "Sidebar collapsed by default for new users": "預設情況下為新用戶摺疊側邊欄", "Sidebar modules": "側邊欄模組", @@ -4220,6 +4356,9 @@ "Special usable group rules make extra token groups visible to, or hide default ones from, users of a specific user group.": "特殊可用分組規則可以讓特定用戶分組的用戶額外看到某些令牌分組,或對其屏蔽預設可選的令牌分組。", "Special visibility rules": "特殊可見性規則", "Spend limited": "消費受限", + "SPIN": "開始抽獎", + "SPINNING": "抽獎中", + "Spinning...": "轉動中...", "SQLite stores all data in a single file. Make sure that file is persisted when running in containers.": "SQLite 將所有數據儲存在單個檔案中。在容器中執行時請確保該檔案已持久化。", "SSL/TLS": "SSL/TLS", "SSRF Protection": "SSRF 保護", @@ -4380,6 +4519,7 @@ "Tag updated successfully": "標籤更新成功", "Tag:": "標籤:", "Tags": "標籤", + "Tap the button below to draw": "點擊下方按鈕開始抽獎", "Take photo": "拍照", "Take screenshot": "截圖", "Target Endpoint": "目標端點", @@ -4434,6 +4574,7 @@ "Text or array of texts to embed": "需要向量化的文字或文字陣列", "Text Output": "文字輸出", "Text to Video": "文生影片", + "Thanks": "謝謝惠顧", "The admin configured three groups and one special ratio rule:": "管理員設定了三個分組和一條特殊倍率規則:", "The admin wants vip users to pay even less when they use premium. That needs an override rule: in the override matrix, set the cell at row vip, column premium to 0.3.": "管理員希望 vip 用戶使用 premium 時價格更低。這就需要一條覆蓋規則:在覆蓋矩陣中,把「行 vip、列 premium」的單元格填成 0.3。", "The administrator account is already initialized. You can keep your existing credentials and continue to the next step.": "管理員用戶已初始化。您可以保留現有憑證並繼續下一步。", @@ -4446,6 +4587,7 @@ "The bound Product powers wallet top-ups: when a user enters any amount, new-api runs the checkout against this single Pancake product and overrides the price per session — no need to pre-create $1 / $5 / $10 SKUs.": "已連結產品用於錢包儲值:當用戶輸入任意金額時,new-api 會基於這個單一 Pancake 產品發起結帳,並按對話覆蓋價格,無需預先建立 $1 / $5 / $10 的 SKU。", "The bound Store is the parent container for every Pancake product new-api creates from this admin — both the wallet top-up product and any subscription-plan products. One store is enough; pin a different one only if you genuinely run separate Pancake catalogs.": "已連結店鋪是 new-api 從此管理端建立的所有 Pancake 產品的父容器,包括錢包儲值產品和訂閱套餐產品。一個店鋪通常足夠;只有在確實運營多個 Pancake 目錄時才需要連結不同店鋪。", "The deployment node that handled the requests": "處理請求的部署節點", + "The download will use the redemption name as the filename.": "兌換碼將以文字檔的形式下載,檔名為兌換碼的名稱。", "The effective domain for Passkey registration. Must match the current domain or be its parent domain.": "用於 Passkey 註冊的有效域。必須與目前域匹配或為其父域。", "The entered text does not match the required text.": "輸入文字與要求文字不匹配。", "The environment (test vs production) is decided by the key you paste here — use the Test key while integrating, then swap to the Production key when going live.": "環境(測試或生產)由你在此貼上的金鑰決定。整合期間使用測試金鑰,上線時再切換為生產金鑰。", @@ -4458,6 +4600,7 @@ "The name displayed across the application": "在整個套用程式中顯示的名稱", "The public URL of your server, used for OAuth callbacks, webhooks, and other external integrations": "伺服器的公開URL,用於OAuthCallback、Webhook和其他外部整合", "The requested chat preset does not exist or has been removed.": "請求的聊天預設不存在或已被刪除。", + "The requested page does not exist, is disabled, or has no URL configured.": "The requested page does not exist, is disabled, or has no URL configured.", "The reset request stays disabled until a credit is available.": "沒有可用次數時,重置請求會保持停用。", "The setup wizard will use this database during initialization.": "設定精靈將在初始化過程中使用此資料庫。", "The site is not available at the moment.": "該站點目前不可用。", @@ -4496,6 +4639,7 @@ "This channel type requires additional configuration": "此渠道類型需要填寫額外設定", "This confirmation unlocks payment, redemption code, subscription plan, and invitation reward features. Please read the statements carefully.": "此確認會解鎖支付、兌換碼、訂閱套餐和邀請獎勵功能。請仔細閱讀相關聲明。", "This controls model request rate limiting. Web/API route throttling is configured by environment variables and may still return 429.": "此處僅控制模型請求速率限制。Web/API 路由限流由環境變數設定,仍可能返回 429。", + "This custom page will be removed from the list.": "This custom page will be removed from the list.", "This data may be unreliable, use with caution": "此數據可能不可靠,請謹慎使用", "This device does not support Passkey": "此設備不支援 Passkey", "This device does not support Passkey verification.": "此設備不支援 Passkey 驗證。", @@ -4515,6 +4659,7 @@ "This model is not available in any group, or no group pricing information is configured.": "此模型在任何分組中均不可用,或未設定分組定價資訊。", "This month": "本月獲得", "This page has not been created yet.": "此頁面尚未建立。", + "This page opens in a new browser tab because the target site cannot be embedded.": "This page opens in a new browser tab because the target site cannot be embedded.", "This plan does not allow balance redemption": "該套餐不允許使用餘額兌換", "This project must be used in compliance with the": "此項目的使用必須遵守", "This removes {{count}} failed models from this channel. This action cannot be undone.": "此操作將從該渠道移除 {{count}} 個測試失敗的模型,且無法撤銷。", @@ -4570,6 +4715,7 @@ "times": "次", "Timing": "耗時", "Tip": "提示", + "Title": "Title", "to access this resource.": "存取此資源。", "To Anthropic Messages": "轉 Anthropic Messages", "to confirm": "以確認", @@ -4728,7 +4874,9 @@ "UI granularity only — data is still aggregated hourly": "僅 UI 粒度 — 數據仍按小時匯總", "Unable to estimate price for this deployment.": "無法為該部署估算價格。", "Unable to generate chat link. Please contact your administrator.": "無法生成聊天連結。請聯絡您的管理員。", + "Unable to load availability": "Unable to load availability", "Unable to load groups": "無法載入分組", + "Unable to load lottery": "無法載入抽獎", "Unable to load rankings": "無法載入排行榜", "Unable to load rankings data": "無法載入排行榜數據", "Unable to open chat": "無法打開聊天", @@ -4747,6 +4895,7 @@ "Unexpected release payload": "意外的版本數據格式", "Unified API Gateway for": "統一 API 閘道,服務於", "Unique identifier for this group.": "此組的唯一標識符。", + "Unit is USD. Internally converted by QuotaPerUnit (default 500000 quota = $1). Doubled on Thursdays.": "單位為美元。內部按 QuotaPerUnit 換算(預設 500000 額度 = $1)。週四翻倍。", "Unit price (local currency / USD)": "單價(本地貨幣 / USD)", "Unit price (USD)": "單價 (USD)", "Unit price must be greater than 0": "單價必須大於 0", @@ -4854,10 +5003,11 @@ "Usage Logs": "使用日誌", "Usage mode": "使用模式", "Usage-based": "基於使用量", - "USD": "USD", + "USD": "美元", "USD Exchange Rate": "美元匯率", "USD price per 1M input tokens.": "每 100 萬輸入 token 的美元價格。", "USD price per 1M tokens.": "每 100 萬 token 的美元價格。", + "Use “Open in new tab” for sites that block iframe embedding (for example Taobao / Xianyu short links).": "Use “Open in new tab” for sites that block iframe embedding (for example Taobao / Xianyu short links).", "Use +: to add a group, -: to remove a default selectable group, or no prefix to append a group.": "使用 +: 新增分組,使用 -: 移除預設可選分組,不加前綴則追加分組。", "Use a compatible browser or device with biometric authentication or a security key to register a Passkey.": "請使用支援生物識別認證或安全金鑰的兼容瀏覽器或設備來註冊通行金鑰。", "Use a different stable value for each instance, then restart the service.": "每個實例使用不同且穩定的值,然後重啟服務。", @@ -4936,6 +5086,7 @@ "Users call the model on the left. The platform forwards the request to the upstream model on the right.": "用戶呼叫左側的模型。平台將請求轉發給右側的上游模型。", "Users in {{group}}": "{{group}} 分組的用戶", "Users must wait for a successful drawing before upscales or variations.": "用戶必須等待成功的繪圖完成,才能進行放大或變體。", + "Users must have a quota upper limit (used + remaining) strictly greater than this USD value. Use this to require recharge (e.g. 5 if signup gift is $5 and min top-up is $10). Set 0 to disable.": "用戶的額度上限(已用 + 剩餘)必須嚴格大於該美元值才可參與。可用於要求已儲值(例如註冊贈送 $5、最低儲值 $10 時可設為 5)。設為 0 表示不限制。", "Users of vip, when billed as premium, pay ratio": "vip 分組的用戶,按 premium 收費時,倍率用", "Users only see groups marked as user selectable. Non-selectable groups can still be assigned by administrators.": "用戶只能看到標記為用戶可選的分組。不可選分組仍可由管理員分配。", "uses": "使用次數", @@ -5010,6 +5161,7 @@ "Violation Marker": "違規標記", "vip": "vip", "VIP users with premium access": "擁有高級存取權限的 VIP 用戶", + "Visibility": "Visibility", "Visible": "可見", "Vision": "視覺", "Vision, image / video, document chat": "視覺理解、圖像 / 影片、文檔對話", @@ -5139,6 +5291,7 @@ "You commit to using upstream APIs, accounts, keys, quotas, and service capabilities only within the scope of lawful authorization obtained from upstream service providers, model service providers, or relevant rights holders, and will not conduct unauthorized resale, trafficking, distribution, or other non-compliant commercialization.": "你承諾僅在從上游服務供應商、模型服務供應商或相關權利人處獲得合法授權的範圍內使用上游 API、用戶、金鑰、額度和服務能力,並不會進行未經授權的轉售、倒賣、分發或其他不合規商業化行為。", "You do not have permission to edit sensitive channel settings.": "你沒有權限編輯敏感渠道設定。", "You don't have necessary permission": "您沒有必要的權限", + "You got {{name}} ({{delta}})": "抽中 {{name}}({{delta}})", "You have legally obtained authorization for the connected model APIs, accounts, keys, and quotas.": "你已合法取得所連接模型 API、用戶、金鑰和額度的授權。", "You have unsaved changes": "您有未儲存的變更", "You have unsaved changes. Are you sure you want to leave?": "您有未儲存的變更。確定要離開嗎?", @@ -5152,6 +5305,7 @@ "Your account cannot edit sensitive channel settings.": "你的賬號不能編輯敏感渠道設定。", "your AI integration?": "你的 AI 整合了嗎?", "Your Azure OpenAI endpoint URL": "您的 Azure OpenAI 端點 URL", + "Your balance": "你的餘額", "Your Bot Name": "您的機械人名稱", "Your Cloudflare Account ID": "您的 Cloudflare 用戶 ID", "Your Discord OAuth Client ID": "您的 Discord OAuth 用戶端 ID", @@ -5159,6 +5313,7 @@ "Your GitHub OAuth Client ID": "您的 GitHub OAuth 用戶端 ID", "Your GitHub OAuth Client Secret": "您的 GitHub OAuth 用戶端密鑰", "Your new backup codes are ready": "您的新備份代碼已準備就緒", + "Your quota": "你的額度", "Your Referral Link": "您的推薦連結", "Your setup guide is collapsed so usage stays in focus.": "設定引導已收起,讓用量資訊保持在焦點位置。", "Your system access token for API authentication. Keep it secure and don't share it with others.": "您的系統存取令牌,用於 API 認證。請妥善保管,不要與他人分享。", diff --git a/web/default/src/i18n/locales/zh.json b/web/default/src/i18n/locales/zh.json index 4f1412ac259a..162fea46986b 100644 --- a/web/default/src/i18n/locales/zh.json +++ b/web/default/src/i18n/locales/zh.json @@ -38,6 +38,8 @@ "{{count}} channel(s) enabled": "已启用 {{count}} 个渠道", "{{count}} channel(s) failed to disable": "{{count}} 个渠道禁用失败", "{{count}} channel(s) failed to enable": "{{count}} 个渠道启用失败", + "{{count}} custom pages deleted. Click \"Save Settings\" to apply.": "已删除 {{count}} 个定制页面。点击“保存设置”以生效。", + "{{count}} custom pages will be removed from the list.": "将从列表中移除 {{count}} 个定制页面。", "{{count}} days ago": "{{count}} 天前", "{{count}} days remaining": "剩余 {{count}} 天", "{{count}} disabled channel(s) deleted": "已删除 {{count}} 个已禁用的渠道", @@ -82,6 +84,7 @@ "+{{count}} more": "还有 {{count}} 项", "| Based on": "| 基于", "0 means data is kept permanently": "0 表示永久保留数据", + "0 means no IP limit.": "0 表示不限制 IP。", "0 means unlimited": "0 表示不限", "1 Day": "1 天", "1 day ago": "1 天前", @@ -118,6 +121,8 @@ "80,443,8080": "80,443,8080", "A billing multiplier. Lower ratios mean lower API call costs.": "计费乘数,倍率越低,API 调用费用越低。", "A focused home for keys, balance, routing, and service health.": "集中展示密钥、余额、路由和服务健康状态。", + "A recommended model is selected automatically. You can change it.": "已自动填入推荐模型,也可手动修改。", + "Abnormal": "异常", "About": "关于", "About {{days}} days left": "约剩 {{days}} 天", "Accept Unpriced Models": "接受未定价模型", @@ -174,6 +179,7 @@ "Add Condition": "添加条件", "Add credits": "添加额度", "Add custom model \"{{value}}\"": "添加自定义模型“{{value}}”", + "Add Custom Page": "添加定制页面", "Add discount tier": "添加折扣等级", "Add each model or tag you want to include.": "添加您想要包含的每个模型或标签。", "Add FAQ": "添加问答", @@ -197,6 +203,7 @@ "Add param/header": "新增参数/Header", "Add payment method": "新增支付方式", "Add photos or files": "添加照片或文件", + "Add prize": "添加奖项", "Add product": "添加产品", "Add Provider": "添加提供商", "Add Quota": "添加配额", @@ -239,6 +246,7 @@ "Administer user accounts and roles.": "管理用户账户和角色。", "Administrator account": "管理员账户", "Administrator username": "管理员用户名", + "Admins only": "仅管理员", "Advance next reset time": "推进下次重置时间", "Advanced": "高级", "Advanced Configuration": "高级配置", @@ -339,7 +347,10 @@ "Allowed": "允许", "Allowed Origins": "允许的 Origins", "Allowed Ports": "允许的端口", + "Allowed: {{min}} – {{max}}": "允许范围:{{min}} – {{max}}", + "Allowed: ${{min}} – ${{max}}": "允许范围:${{min}} – ${{max}}", "Already have an account?": "已有账户?", + "Already spun today": "今日已抽过", "Always matches (default tier).": "始终匹配(默认档位)。", "Amount": "金额", "Amount cannot be changed when editing.": "编辑时无法更改数量。", @@ -387,6 +398,7 @@ "API Key (Sandbox)": "API 密钥(沙盒)", "API Key *": "API 密钥 *", "API Key created successfully": "API 密钥创建成功", + "API key created. Opening the selected tool...": "令牌已创建,正在打开所选工具...", "API Key deleted successfully": "API 密钥删除成功", "API Key disabled successfully": "API 密钥禁用成功", "API Key enabled successfully": "API 密钥启用成功", @@ -449,6 +461,10 @@ "Are you sure?": "您确定吗?", "Area Chart": "面积图", "Args (space separated)": "参数 (空格分隔)", + "Array of {name, multiplier, weight, is_thanks}. Multiplier must be within [-1, 2].": "数组项为 {name, multiplier, weight, is_thanks}。倍率须在 [-1, 2] 之间。", + "Array of {name, multiplier, weight, is_thanks}. Multiplier must be within [-1, 2]. Bet amount itself is in USD.": "数组项为 {name, multiplier, weight, is_thanks}。倍率须在 [-1, 2]。投入金额本身为美元。", + "Array of {name, quota, weight, is_thanks}. Higher quota should use lower weight.": "数组项为 {name, quota, weight, is_thanks}。额度越高权重应越低。", + "Array of {name, usd, weight, is_thanks}. usd is dollars. Higher usd should use lower weight.": "数组项为 {name, usd, weight, is_thanks}。usd 为美元金额。金额越高权重应越低。", "Array of chat client presets. Each item is an object with one key-value pair: client name and its URL.": "聊天客户端预设数组。每个项目都是一个对象,包含一个键值对:客户端名称及其 URL。", "Asc": "升序", "Ask anything": "随便问", @@ -520,7 +536,9 @@ "Automatically replaces upstream callback URLs with the server address.": "自动将上游回调 URL 替换为服务器地址。", "Automatically selects the best available group with circuit breaker mechanism": "自动选择可用分组,失败时触发熔断切换", "Automatically sync model list when upstream changes are detected": "检测到上游模型变更时自动同步模型列表", + "Availability": "可用性", "Availability (last 24h)": "可用率(最近 24 小时)", + "Availability Monitor": "可用性监控", "Available": "可用", "Available credits are ordered by soonest expiration.": "可用次数按最早到期排序。", "Available disk space": "可用磁盘空间", @@ -535,6 +553,7 @@ "Average tokens per second sustained per group": "各分组持续输出的平均每秒 token 数", "Average TPM": "平均 TPM", "Average TTFT": "平均首 Token 延迟", + "Avg latency": "平均延迟", "AWS": "AWS", "AWS Bedrock Claude Compat": "AWS Bedrock Claude 兼容模板", "AWS Key Format": "AWS 密钥格式", @@ -558,6 +577,7 @@ "Baidu V2": "百度 V2", "Balance": "余额", "Balance and top-up management": "余额充值管理", + "Balance change": "余额变化", "Balance depleted": "余额已耗尽", "Balance is shown in quota units": "余额以额度单位显示", "Balance queried successfully": "余额查询成功", @@ -604,6 +624,15 @@ "Batch upstream model updates applied: {{channels}} channels, {{added}} added, {{removed}} removed, {{fails}} failed": "已批量处理上游模型更新:渠道 {{channels}} 个,加入 {{added}} 个,删除 {{removed}} 个,失败 {{fails}} 个", "Best for single-tenant deployments. Pricing and billing options stay hidden.": "适合单用户部署。定价和计费选项将被隐藏。", "Best TTFT": "最优 TTFT", + "Bet amount": "投入金额", + "Bet amount (USD)": "投入金额(美元)", + "Bet amount is out of range": "投入金额超出允许范围", + "Bet cannot exceed your current balance": "投入金额不能超过当前余额", + "Bet cannot exceed your current quota": "投入额度不能超过当前余额", + "Bet mode prizes": "投入模式奖项", + "Bet prizes JSON": "投入模式奖项 JSON", + "Bet with quota": "投入额度抽奖", + "Bet with USD": "投入美元抽奖", "Billable input tokens": "计费输入 token", "Billable output tokens": "计费输出 token", "Billed as default. No cell for this combination, so the base ratio of default applies — the 0.8 of vip plays no part.": "按 default 计费。矩阵中没有这个组合的格子,因此用 default 的基础倍率——vip 自己的 0.8 不参与。", @@ -814,6 +843,8 @@ "Choose the default charts, range, and time granularity for model analytics.": "选择模型调用分析的默认图表、范围和时间粒度。", "Choose where to fetch upstream metadata.": "选择从何处获取上游元数据。", "Choose which charts are selected by default when opening model analytics.": "选择打开模型调用分析时默认选中的图表。", + "Choose who can see the Availability Monitor entry in the Extensions sidebar.": "选择谁可以在侧栏「拓展」中看到可用性监控入口。", + "Choose who can see this page in the Extensions sidebar.": "选择谁可以在侧栏「拓展」中看到此页面。", "Clamped to": "钳制为", "Classic (Legacy Frontend)": "经典前端", "Claude": "Claude", @@ -949,6 +980,7 @@ "Configuration for Epay payment integration": "Epay 支付集成的配置", "Configuration for Stripe payment integration": "Stripe 支付集成的配置", "Configuration required": "需要配置", + "Configuration tool": "配置工具", "Configure": "配置", "Configure a Creem product for user recharge options.": "为用户充值选项配置 Creem 产品。", "Configure a custom ratio for when users use a specific token group.": "配置用户使用特定令牌分组时的自定义倍率。", @@ -974,6 +1006,8 @@ "Configure rate limiting rules for a specific user group.": "配置特定用户分组的速率限制规则。", "Configure routes": "配置路由", "Configure the ratio for this group.": "配置此分组的比例。", + "Configure the sidebar title, icon, embed URL, status, and sort order.": "配置侧栏标题、图标、嵌入 URL、状态与排序。", + "Configure the sidebar title, icon, URL, open mode, status, and sort order.": "配置侧栏标题、图标、URL、打开方式、状态与排序。", "Configure upstream providers and routing.": "配置上游提供者和路由。", "Configure Waffo Pancake hosted checkout integration for USD-priced top-ups": "配置 Waffo Pancake 托管结账,用于美元计价的充值", "Configure Waffo payment aggregation platform integration": "配置 Waffo 支付聚合平台集成", @@ -981,6 +1015,7 @@ "Configure your account preferences and integrations": "配置您的账户偏好和集成", "Configured as PayMethods JSON. The type value decides which payment flow is used: stripe for Stripe, waffo_pancake for Waffo Pancake, and other values are sent to Epay as the type parameter.": "保存为 PayMethods JSON。type 值决定点击后使用哪个支付流程:stripe 走 Stripe,waffo_pancake 走 Waffo Pancake,其他值作为 Epay 的 type 参数提交。", "Configured routes and latency checks": "已配置路由和延迟检测", + "Configuring...": "配置中...", "Confirm": "确认", "Confirm Action": "确认操作", "Confirm and enable": "确认并启用", @@ -1015,6 +1050,7 @@ "Conflict": "矛盾", "Connect": "连接", "Connect through OpenAI, Claude, Gemini, and other compatible API routes": "通过 OpenAI、Claude、Gemini 以及其他兼容 API 路由接入", + "Connect tool": "连接工具", "Connected to io.net service normally.": "已正常连接 io.net 服务。", "Connection closed": "连接已关闭", "Connection error": "连接错误", @@ -1115,10 +1151,12 @@ "Cost = model price × that one ratio. Nothing else from the group settings enters the formula.": "费用 = 模型价格 × 这一个倍率。分组设置里的其他项都不参与该公式。", "Cost in USD per request, regardless of tokens used.": "每请求的美元费用,不考虑使用的令牌数。", "Cost Tracking": "成本跟踪", + "Could not load pricing data. Open the pricing page or refresh and try again.": "无法加载定价数据。请打开模型定价页或刷新后重试。", "Count must be between {{min}} and {{max}}": "计数必须介于{{min}}和{{max}}之间", "Coze": "Coze", "CPU": "CPU", "CPU Threshold (%)": "CPU 阈值 (%)", + "Crazy Thursday!": "疯狂星期四!", "Create": "新建", "Create a copy of:": "创建副本:", "Create a key for your app or service": "为你的应用或服务创建密钥", @@ -1126,6 +1164,7 @@ "Create account": "创建账户", "Create an account": "创建一个账户", "Create an API key to unlock the real request": "创建 API 密钥以解锁真实请求", + "Create and configure": "创建并配置", "Create and review invite or credit codes.": "创建和审查邀请或信用代码。", "Create API Key": "创建 API 密钥", "Create cache": "创建缓存", @@ -1214,6 +1253,12 @@ "Custom multipliers when specific user groups use specific token groups. Example: VIP users get 0.9x rate when using \"edit_this\" group tokens.": "当特定用户分组使用特定令牌分组时的自定义乘数。示例:VIP 用户在使用“edit_this”分组令牌时获得 0.9 倍费率。", "Custom OAuth": "自定义 OAuth", "Custom OAuth Providers": "自定义OAuth提供商", + "Custom page added. Click \"Save Settings\" to apply.": "定制页面已添加。点击“保存设置”以生效。", + "Custom page deleted. Click \"Save Settings\" to apply.": "定制页面已删除。点击“保存设置”以生效。", + "Custom page not found": "未找到定制页面", + "Custom page updated. Click \"Save Settings\" to apply.": "定制页面已更新。点击“保存设置”以生效。", + "Custom Pages": "定制页面", + "Custom pages saved successfully": "定制页面保存成功", "Custom Seconds": "自定义秒数", "Custom sidebar section": "自定义侧边栏部分", "Custom Time Range": "自定义时间范围", @@ -1221,6 +1266,12 @@ "Customize sidebar display content": "个性化设置左侧边栏的显示内容", "Daily": "每天", "Daily Check-in": "每日签到", + "Daily prize pool": "每日奖池", + "Daily prize pool (USD)": "每日奖池(美元)", + "Display daily prize pool (USD)": "展示每日奖池(美元)", + "Shown to users on the lottery page. Does not limit real payouts. Doubled on Thursdays. Set 0 to fall back to the actual pool.": "显示在用户抽奖页顶部,不限制真实发奖。周四翻倍。设为 0 则回退为实际奖池限额。", + "Actual daily pool limit (USD)": "实际每日奖池限额(美元)", + "Real daily payout cap used by the backend. Hidden from users. Doubled on Thursdays.": "后端真实发奖上限,不对用户展示。周四翻倍。", "Daily token usage by model across the past few weeks": "过去几周内按模型分布的每日 Token 使用量", "Daily token usage by model across the past month": "过去一个月内各模型的每日 Token 用量", "Daily token usage by model over the past month": "过去一个月内按模型分布的每日 Token 使用量", @@ -1428,7 +1479,9 @@ "Do not wait one second between polling async tasks for this channel": "该渠道轮询异步任务时不等待一秒", "Do regex replacement in the target field": "在目标字段里做正则替换", "Do string replacement in the target field": "在目标字段里做字符串替换", + "Do you want to download the created redemption codes as a text file?": "兑换码创建成功,是否下载兑换码?", "Docs": "文档", + "Documentation": "文档", "Documentation Link": "文档链接", "Documentation or external knowledge base.": "文档或外部知识库。", "does not exist or might have been removed.": "不存在或可能已被移除。", @@ -1439,6 +1492,7 @@ "Doubao custom API address editing unlocked": "已解锁豆包自定义 API 地址编辑", "DoubaoVideo": "DoubaoVideo", "Double check the configuration below. Your system will be locked until initialization is complete.": "仔细检查以下配置。您的系统将在初始化完成前保持锁定状态。", + "Doubled automatically on Thursdays.": "周四自动翻倍。", "Downgrade Group": "降级分组", "Downgrade to pre-purchase group": "降级到购买前分组", "Downgrade to this group after the subscription expires": "订阅过期后降级到该分组", @@ -1507,6 +1561,7 @@ "Each item must have exactly one key-value pair.": "每个条目必须恰好包含一个键值对。", "Each line represents one keyword. Leave blank to disable the list but keep the switch states.": "每行代表一个关键词。留空以禁用列表,但保留开关状态。", "Each matrix cell is one rule: users of this row group pay this ratio when billed as this column group. In JSON the row is the outer key and the column is the inner key.": "矩阵的每个单元格是一条规则:该行用户分组的用户按该列分组计费时使用此倍率。在 JSON 中行是外层键,列是内层键。", + "Each row is a prize. Higher USD should usually have lower weight.": "每一行是一个奖项。金额越高,权重通常应越低。", "Each rule reads as a sentence: users of one group pay a special ratio when billed as another group. Without a rule, the billing group base ratio applies.": "每条规则就是一句话:某分组的用户按另一分组计费时享受特殊倍率。没有规则时,使用计费分组的基础倍率。", "Each tier supports 0~2 conditions (over len, p, c); the last tier is the catch-all without conditions. Use len (full input length, including cache hits) for tier conditions to avoid mis-routing when cache hits reduce p.": "每个档位支持 0~2 个条件(针对 len、p、c),最后一档为兜底档无需条件。建议条件使用 len(完整输入长度,含缓存命中),避免缓存命中降低 p 导致档位误判。", "Each tier supports up to 2 conditions; the last tier is the catch-all without conditions. Use full input length for tier conditions to avoid mis-routing when cache hits reduce billable input tokens.": "每个档位最多支持 2 个条件;最后一个档位是不带条件的兜底档。建议使用完整输入长度作为档位条件,避免缓存命中减少计费输入 token 后误判档位。", @@ -1521,6 +1576,7 @@ "Edit Channel": "编辑渠道", "Edit channel routing": "编辑渠道路由", "Edit chat preset": "编辑聊天预设", + "Edit Custom Page": "编辑定制页面", "Edit discount tier": "编辑折扣档位", "Edit FAQ": "编辑常见问题", "Edit group": "编辑分组", @@ -1557,6 +1613,7 @@ "Email Field": "邮箱字段", "Email Verification": "电子邮件验证", "Email, summarisation, knowledge work": "邮件、摘要与知识工作", + "Embed in console": "控制台内嵌", "Embeddings": "嵌入", "Empty": "空", "Empty value will be saved as {}.": "空值将保存为 {}。", @@ -1564,6 +1621,7 @@ "Enable {{parameter}}": "启用 {{parameter}}", "Enable 2FA": "启用 2FA", "Enable All": "启用全部", + "Enable availability monitor": "启用可用性监控", "Enable check-in feature": "启用签到功能", "Enable Data Dashboard": "启用数据仪表板", "Enable demo mode with limited functionality": "启用功能受限的演示模式", @@ -1578,6 +1636,7 @@ "Enable io.net deployments": "启用 io.net 部署", "Enable io.net model deployment service in console": "在控制台启用 io.net 模型部署服务", "Enable LinuxDO OAuth": "启用 LinuxDO OAuth", + "Enable lucky slot": "启用幸运老虎机", "Enable model performance metrics": "启用模型性能指标", "Enable OIDC": "启用 OIDC", "Enable or disable this channel": "启用或禁用此渠道", @@ -1606,6 +1665,7 @@ "Enabled": "已启用", "Enabled all channels with tag: {{tag}}": "已启用标签「{{tag}}」下的所有渠道", "Enabled channels with tag {{tag}}": "启用标签为 {{tag}} 的渠道", + "Enabled pages with a URL appear under the Extensions group in the console sidebar and open as embedded pages.": "已启用且填写了 URL 的页面会出现在控制台侧栏「拓展」分组中,并以内嵌页面打开。", "Enabled Status": "启用状态", "Enabling...": "正在启用...", "Encourages introducing new topics": "鼓励引入新话题", @@ -1721,6 +1781,7 @@ "Estimated cost": "预计成本", "Estimated quota cost": "估算配额费用", "Every group name in the pricing table can be used in two places: on a user (the user group, assigned by admins) and on a token (the token group, chosen when creating the token). Same name pool, two different jobs.": "定价表中的每个分组名可用在两个地方:用户身上(用户分组,由管理员分配)和令牌身上(令牌分组,创建令牌时选择)。同一批名字,两种不同职责。", + "Everyone": "所有人", "Everything configured for this group, in one place.": "该分组的全部配置,一处看全。", "Exact": "精确", "Exact Match": "完全匹配", @@ -1765,6 +1826,7 @@ "Extend deployment": "延长部署", "Extend failed": "延长失败", "Extended successfully": "延长成功", + "Extensions": "拓展", "External Device": "外部设备", "External link for users to purchase quota": "供用户购买配额的外部链接", "External operations": "对外运营", @@ -1843,12 +1905,14 @@ "Failed to initialize system": "系统初始化失败", "Failed to load": "加载失败", "Failed to load API keys": "加载 API 密钥失败", + "Failed to load availability": "加载可用性数据失败", "Failed to load billing history": "加载计费历史失败", "Failed to load enabled models": "获取启用模型失败", "Failed to load home page content": "加载首页内容失败", "Failed to load image": "无法加载图像", "Failed to load key status": "加载密钥状态失败", "Failed to load logs": "加载日志失败", + "Failed to load lottery status": "获取抽奖状态失败", "Failed to load Passkey status": "加载 Passkey 状态失败", "Failed to load playground groups": "加载 playground 分组失败", "Failed to load playground models": "加载 playground 模型失败", @@ -1874,7 +1938,9 @@ "Failed to save": "保存失败", "Failed to save announcements": "保存公告失败", "Failed to save API info": "保存 API 信息失败", + "Failed to save custom pages": "保存定制页面失败", "Failed to save FAQ": "保存 FAQ 失败", + "Failed to save settings": "保存设置失败", "Failed to save Uptime Kuma groups": "保存 Uptime Kuma 组失败", "Failed to search API keys": "搜索 API 密钥失败", "Failed to search redemption codes": "搜索兑换码失败", @@ -1965,8 +2031,8 @@ "Filter by MjProxy task ID": "按 MjProxy 任务 ID 筛选", "Filter by model name...": "按模型名称筛选...", "Filter by model...": "按模型筛选...", - "Filter by name or ID...": "按名称或 ID 筛选...", "Filter by name, ID, or key...": "按名称、ID 或密钥筛选...", + "Filter by name, ID, or redemption code...": "按名称、ID 或兑换码筛选...", "Filter by name...": "按名称筛选...", "Filter by node": "按节点筛选", "Filter by price field": "按价格字段筛选", @@ -2024,7 +2090,7 @@ "footer.columns.related.links.oneApi": "One API", "footer.columns.related.title": "相关项目", "footer.defaultCopyright": "版权所有。", - "footer.new\u0061pi.projectAttributionSuffix": "版权所有,由项目贡献者设计与开发。", + "footer.newapi.projectAttributionSuffix": "版权所有,由项目贡献者设计与开发。", "For channels added after May 10, 2025, no need to remove \".\" from model names during deployment": "对于 2025 年 5 月 10 日之后添加的渠道,在部署时无需从模型名称中移除 \".\"", "For private deployments, format: https://fastgpt.run/api/openapi": "对于私有部署,格式为:https://fastgpt.run/api/openapi", "Force a syntactically valid JSON response": "强制返回语法合法的 JSON", @@ -2051,6 +2117,9 @@ "Forward requests directly to upstream providers without any post-processing.": "将请求直接转发给上游提供商,不进行任何后处理。", "Frames per second": "帧率", "Free": "可用", + "Free mode prizes": "免费模式奖项", + "Free prizes JSON": "免费模式奖项 JSON", + "Free prizes JSON (USD)": "免费模式奖项 JSON(美元)", "Free: {{free}} / Total: {{total}}": "可用空间: {{free}} / 总空间: {{total}}", "Frequency Penalty": "频率惩罚", "Friendly name to identify this channel": "用于识别此渠道的友好名称", @@ -2112,6 +2181,8 @@ "Go to settings": "前往设置", "Go to Settings": "前往设置", "Good": "良好", + "Got it": "知道了", + "Congratulations!": "恭喜中奖!", "Gotify Application Token": "Gotify 应用令牌", "Gotify Documentation": "Gotify 文档", "Gotify Server URL": "Gotify 服务器 URL", @@ -2216,6 +2287,7 @@ "How It Works": "工作流程", "How model mapping works": "模型映射如何工作", "How much to charge for each US dollar of balance (Epay)": "每美元余额(Epay)的收费金额", + "How often the Availability Monitor page automatically reloads data. Allowed range: 5–3600 seconds.": "可用性监控页面自动刷新数据的间隔。允许范围:5–3600 秒。", "How this model name should match requests": "此模型名称应如何匹配请求", "How to deliver the resulting image": "图像结果的返回方式", "How to get an io.net API Key": "如何获取 io.net API 密钥", @@ -2257,6 +2329,7 @@ "If connecting to upstream One API or New API relay projects, use OpenAI type instead unless you know what you are doing": "如果连接上游 One API 或 New API 中继项目,除非您知道自己在做什么,否则请使用 OpenAI 类型", "If default auto group is enabled, newly created tokens start with auto instead of an empty group.": "如果启用默认 auto 分组,新建令牌会默认使用 auto,而不是空分组。", "If the affinity channel fails and retry succeeds on another channel, update affinity to the successful channel.": "如果亲和到的渠道失败,重试到其他渠道成功后,将亲和更新到成功的渠道。", + "If the app did not open, install the tool and use this API key manually:": "如果应用未打开,请先安装工具,并使用下面的密钥手动配置:", "If this keeps happening, please report it on GitHub Issues.": "如果问题持续出现,请到 GitHub Issues 反馈。", "If you provide generative AI services to the public in mainland China, you will fulfill legal obligations including filing, security assessment, content safety, complaint handling, generated content labeling, log retention, and personal information protection.": "如果你在中国大陆向公众提供生成式人工智能服务,你将履行备案、安全评估、内容安全、投诉处理、生成内容标识、日志留存和个人信息保护等法律义务。", "Ignore": "忽略", @@ -2487,6 +2560,7 @@ "Load template...": "加载模板...", "Loader": "加载器", "Loading": "加载中", + "Loading available providers...": "正在加载可用类型…", "Loading channel details": "正在加载渠道详情", "Loading configuration": "正在加载配置", "Loading content settings...": "正在加载内容设置...", @@ -2526,8 +2600,13 @@ "Logo URL": "徽标 URL", "Logs": "日志", "Look for a special ratio rule matching this user group and this billing group. If one exists, use its ratio. Otherwise use the billing group base ratio from the pricing table.": "查找匹配「该用户分组 + 该计费分组」的特殊倍率规则。有就用规则里的倍率,没有就用定价分组表中计费分组的基础倍率。", + "Lottery draw failed": "抽奖失败", "Low balance": "余额偏低", + "Lower numbers appear first in the sidebar.": "数字越小,侧栏中排序越靠前。", "Lowest median first-token latency": "最低首 token 延迟中位数", + "Lucky Slot": "幸运老虎机", + "Lucky Slot Lottery": "幸运老虎机抽奖", + "Lucky Slot Machine": "幸运老虎机", "m": "分钟", "Maintenance": "维护", "Make extra groups visible to, or hide default groups from, users of a specific group.": "让特定分组的用户额外看到某些分组,或对其屏蔽默认可选的分组。", @@ -2572,7 +2651,10 @@ "Matched Tier": "命中阶梯", "Matches models not claimed by earlier splits.": "匹配前面分流未占用的模型。", "Matching Rules": "匹配规则", + "Max bet": "最大投入", + "Max bet (USD)": "最大投入(美元)", "Max Disk Cache Size (MB)": "磁盘缓存最大总量 (MB)", + "Max draws per IP / day": "同 IP 每日最多抽奖次数", "Max Entries": "最大条目数", "Max output": "最大输出", "Max Requests (incl. failures)": "最大请求数(包括失败)", @@ -2607,6 +2689,13 @@ "Merge into Other": "合并为其他", "Message Priority": "消息优先级", "Metadata": "元信息", + "Min bet": "最小投入", + "Min bet (USD)": "最小投入(美元)", + "Min bet cannot exceed max bet": "最小投入不能大于最大投入", + "Require redemption code to play": "参与需先兑换过兑换码", + "Redeem code required": "需要先兑换兑换码", + "Please redeem a code before playing. Crazy Thursday does not require this.": "请先使用兑换码充值后再参与抽奖。疯狂星期四无需兑换。", + "On normal days, users must have redeemed at least one code. Crazy Thursday skips this requirement.": "平时需至少成功兑换过一次兑换码才可参与;疯狂星期四不限制。", "min downtime": "分钟停机", "Min Top-up": "最低充值", "Min Top-up:": "最低充值:", @@ -2768,9 +2857,11 @@ "Multiplier for completion tokens.": "补全令牌的倍数。", "Multiplier for image processing.": "图像处理的倍率。", "Multiplier for prompt tokens.": "提示令牌的倍数。", + "Multiplier is relative to bet amount. Range: -1 to 2.": "倍率为相对投入金额。范围:-1 到 2。", "Multipliers for recharge pricing based on user groups.": "基于用户分组的充值定价倍率。", "Must be a valid URL": "必须是有效的 URL", "Must be at least 8 characters": "必须至少 8 个字符", + "Must be http(s). Leave empty to keep the page hidden from the sidebar.": "必须为 http(s)。留空则不会出现在侧栏中。", "My Subscriptions": "我的订阅", "my-status": "我的状态", "MySQL detected": "检测到 MySQL", @@ -2812,6 +2903,7 @@ "New password": "新密码", "New Password": "新密码", "New password must be different from current password": "新密码必须与当前密码不同", + "New prize": "新奖项", "New User Quota": "新用户配额", "New version available: {{version}}": "有新版本可用:{{version}}", "NewAPI": "NewAPI", @@ -2841,6 +2933,7 @@ "No available Web chat links": "没有可用的 Web 聊天链接", "No backup": "无备份", "No base input price": "未设置基础输入价格", + "No billing groups configured.": "尚未配置计费分组。", "No billing records found": "未找到账单记录", "No capabilities reported for this model.": "该模型暂未报告任何能力。", "No Change": "无变化", @@ -2862,6 +2955,7 @@ "No containers": "无容器", "No content to copy": "没有可复制的内容", "No custom OAuth providers configured yet.": "尚未配置自定义 OAuth 提供商。", + "No custom pages yet. Click \"Add Custom Page\" to create one.": "暂无定制页面。点击“添加定制页面”创建。", "No data": "暂无数据", "No Data": "无数据", "No data available": "暂无数据", @@ -2880,6 +2974,7 @@ "No group": "未设置", "No group found.": "未找到分组。", "No group-based rate limits configured. Click \"Add group\" to get started.": "未配置基于组的速率限制。点击“添加组”开始使用。", + "No groups available for this provider type": "该类型下暂无可用分组", "No groups match your search": "没有组匹配您的搜索", "No groups yet. Add a group to get started.": "暂无分组,添加一个分组开始配置。", "No header overrides configured.": "未配置标头覆盖。", @@ -2942,9 +3037,13 @@ "No processable upstream model updates for this channel": "该渠道暂无可处理的上游模型更新", "No products configured. Click \"Add product\" to get started.": "未配置产品。点击 \"添加产品\" 开始。", "No products match your search": "没有产品匹配您的搜索", + "No provider types are available for your current groups.": "当前可用分组下没有可选的类型。", + "No provider types are available. Types unlock when pricing models match Anthropic / OpenAI / Gemini / xAI for your groups — having channels alone is not enough.": "没有可选类型。需要定价中存在匹配 Anthropic / OpenAI / Gemini / xAI 且你可用分组可访问的模型;仅有渠道不会解锁类型。", + "No provider types are available. Types unlock when pricing models match Anthropic / OpenAI for your groups — having channels alone is not enough.": "没有可选类型。需要定价中存在匹配 Anthropic / OpenAI 且你可用分组可访问的模型;仅有渠道不会解锁类型。", "No providers available": "暂无可用提供商", "No Quota": "无余额", "No ratio differences found": "未发现比率差异", + "No recent requests for this group.": "该分组暂无近期请求。", "No recent usage": "暂无使用记录", "No records found. Try adjusting your filters.": "未找到记录。尝试调整您的筛选条件。", "No redemption codes available. Create your first redemption code to get started.": "没有可用的兑换码。创建您的第一个兑换码即可开始使用。", @@ -2996,6 +3095,7 @@ "Non-zero invitation rewards require compliance confirmation in Payment Gateway settings.": "非零邀请奖励需要先在支付网关设置中确认合规条款。", "None": "无", "noreply@example.com": "noreply@example.com", + "Normal": "正常", "Normalized:": "已归一化:", "Not available": "不可用", "Not backed up": "未备份", @@ -3016,6 +3116,7 @@ "Notification Email": "通知邮箱", "Notification Method": "通知方式", "Notifications": "通知", + "Now": "现在", "Now a user whose user group is vip creates tokens with different groups and makes one call with each:": "现在,一个用户分组为 vip 的用户创建了不同分组的令牌,各调用一次:", "Nucleus sampling probability mass": "核采样累计概率", "Number of codes to create": "要创建的代码数量", @@ -3076,6 +3177,7 @@ "Only available for admins. When enabled, you will receive a summary notification via your selected method when the scheduled model check detects upstream model changes or check failures.": "仅管理员可用。启用后,当定时模型检查检测到上游模型变更或检查失败时,您将通过所选方式收到汇总通知。", "Only configured combinations are overridden. All other calls keep the billing group base ratio.": "只有配置过的组合才会被覆盖,其余调用仍使用计费分组的基础倍率。", "Only configured combinations are overridden. All other calls keep the token group base ratio.": "只有已配置的组合会被覆盖,其他调用仍使用令牌分组的基础倍率。", + "Only enabled pages with a URL are shown in the Extensions sidebar group.": "仅「已启用」且填写了 URL 的页面会显示在侧栏「拓展」分组中。", "Only enabled parameters are sent with the request.": "只有启用的参数会随请求发送。", "Only enter the site origin, for example https://api.example.com. Do not include any path such as /api/user/epay/notify. Leave blank to use the server address.": "只填写站点根域名,例如 https://api.example.com。不要填写 /api/user/epay/notify 这类路径。留空则使用服务器地址。", "Only Mine": "仅自己", @@ -3083,6 +3185,7 @@ "Only one OpenAI Models route is allowed": "仅允许配置一条 OpenAI 模型路由", "Only selected fields will be overwritten. You can re-run the sync wizard if new conflicts appear.": "仅选定的字段将被覆盖。如果出现新的冲突,您可以重新运行同步向导。", "Only successful requests": "仅成功的请求", + "Only recharged users can play. Your quota upper limit must be greater than ${{amount}}.": "仅充值用户可参与抽奖。你的额度上限需大于 ${{amount}}。", "Only successful requests count toward this limit.": "仅成功的请求计入此限制。", "Only the last {{value}} log files will be retained; the rest will be deleted.": "将只保留最近 {{value}} 个日志文件,其余将被删除。", "Oops! Page Not Found!": "糟糕!页面未找到!", @@ -3091,9 +3194,10 @@ "Open a source model first": "请先打开一个源模型", "Open CC Switch": "打开 CC Switch", "Open in chat": "在聊天中打开", - "Open in new tab": "在新标签页中打开", + "Open in new tab": "新标签页打开", "Open in New Tab": "在新标签页中打开", "Open menu": "打开菜单", + "Open mode": "打开方式", "Open release": "打开版本", "Open source": "开源", "Open Source": "开源项目", @@ -3142,6 +3246,8 @@ "Optional settings for advanced container configuration.": "高级容器配置的可选设置。", "Optional supplementary information (max 100 characters)": "可选补充信息 (最多 100 个字符)", "Optional tag for grouping channels": "用于分组渠道的可选标签", + "Optional. Max net win is 2x bet; you may also lose balance.": "可选。最大净收益为投入的 2 倍,也可能扣减余额。", + "Optional. Max net win is 2x bet; you may also lose quota.": "可选。最大净收益为投入的 2 倍,也可能扣减额度。", "Opus Model": "Opus 模型", "Or continue with": "或继续使用", "Or enter this key manually:": "或手动输入此密钥:", @@ -3253,6 +3359,7 @@ "Password reset: {{password}}": "密码已重置:{{password}}", "Passwords do not match": "密码不匹配", "Passwords don't match.": "两次输入的密码不一致。", + "Past": "过去", "Paste Connection Info": "粘贴连接信息", "Path": "路径", "Path not set": "未设置路径", @@ -3326,8 +3433,11 @@ "Personal use": "个人使用", "Personal use mode": "个人使用模式", "Pick a date": "选择日期", + "Pick a provider type, group, model, and client. We create an API key and open the tool for you.": "选择类型、分组、模型和客户端。系统会创建令牌并打开对应工具完成配置。", "Pick or create both a store and a product before saving.": "保存前请同时选择或创建店铺和产品。", "Ping Interval (seconds)": "Ping 间隔(秒)", + "Pity prize triggered": "已触发保底奖项", + "Pity progress": "保底进度", "Plan": "套餐", "Plan Name": "套餐名称", "Plan Price": "套餐价格", @@ -3342,6 +3452,7 @@ "Playground and chat functions": "操练场和聊天功能", "Playground experiments and live conversations.": "Playground 实验和实时对话。", "Please agree to the legal terms first": "请先同意法律条款", + "Please complete the human verification first": "请先完成人机验证", "Please complete the security check to continue.": "请完成安全验证以继续。", "Please confirm that you understand the consequences": "请确认您了解后果", "Please confirm your password": "请确认密码", @@ -3486,6 +3597,8 @@ "Priority order for tokens in the auto group. The system tries groups from top to bottom.": "auto 分组令牌的优先顺序。系统会从上到下依次尝试各分组。", "Privacy Policy": "隐私政策", "Private Deployment URL": "私有部署 URL", + "Prize JSON is invalid": "奖项 JSON 无效", + "Prize pool and free prize amounts are doubled today. V me 50!": "今日奖池与免费奖项金额均已翻倍。V 我 50!", "Processing OAuth response...": "正在处理 OAuth 响应...", "Processing...": "处理中...", "Product": "产品", @@ -3511,6 +3624,11 @@ "Prompt price ($/1M tokens)": "提示词价格(美元/100 万 token)", "Proprietary": "商业闭源", "Protect login and registration with Cloudflare Turnstile": "使用 Cloudflare Turnstile 保护登录和注册", + "Protect login, registration and lottery draws with Cloudflare Turnstile": "使用 Cloudflare Turnstile 保护登录、注册和抽奖", + "Unable to enable Turnstile. Please fill in the Turnstile site key first.": "无法启用 Turnstile,请先填写站点密钥(Site Key)。", + "Public site key from Cloudflare Turnstile. Required for the widget to render.": "Cloudflare Turnstile 的公开站点密钥,用于页面渲染验证组件。", + "If already saved, this field shows ********. Leave it unchanged unless you need to replace the secret.": "若已保存会显示 ********,无需改动;只有要更换密钥时才重新填写。", + "No changes to save": "没有需要保存的更改", "Provide a JSON object where each key maps to an endpoint definition.": "提供一个 JSON 对象,其中每个键映射到一个端点定义。", "Provide a valid URL starting with http:// or https://": "请提供以 http:// 或 https:// 开头的有效 URL", "Provide Markdown, HTML, or an external URL for the privacy policy": "提供 Markdown、HTML 或外部 URL 作为隐私政策", @@ -3522,6 +3640,7 @@ "Provider created successfully": "提供商创建成功", "Provider deleted successfully": "提供商删除成功", "Provider Name": "提供商名称", + "Provider type": "类型", "Provider type (OpenAI, Anthropic, etc.)": "提供商类型 (OpenAI、Anthropic 等)", "Provider updated successfully": "提供商更新成功", "Provider-specific endpoint, account, and compatibility settings.": "配置供应商专属的端点、账户和兼容性选项。", @@ -3537,6 +3656,7 @@ "Published:": "已发布:", "Pull": "拉取", "Pull model": "拉取模型", + "Pull to spin": "拉动拉杆抽奖", "Pulling...": "拉取中...", "Purchase Limit": "限购", "Purchase limit reached": "已达到购买上限", @@ -3559,6 +3679,7 @@ "Quota": "额度", "Quota ({{currency}})": "额度 ({{currency}})", "Quota adjusted successfully": "调整额度成功", + "Quota change": "额度变化", "Quota clamped": "额度已钳制", "Quota consumed before charging users": "向用户收费前消耗的配额", "Quota Distribution": "消耗分布", @@ -3613,6 +3734,7 @@ "Receive Upstream Model Update Notifications": "接收上游模型更新通知", "Received": "获得", "Received amount": "已收额度", + "Recent {{count}} records": "近 {{count}} 次记录", "Recent maintenance tasks running across instances and their execution status.": "跨实例运行的近期维护任务及其执行状态。", "Recently completed or failed system task runs.": "最近已完成或失败的系统任务运行记录。", "Recently launched models": "近期发布的模型", @@ -3620,6 +3742,7 @@ "Recharge": "充值", "Recharge Amount": "充值金额", "Recharge Amount (USD)": "充值金额 (USD)", + "Recharge required": "需要充值", "Recommended": "推荐", "Recommended actions": "推荐操作", "Recommended to keep this high to avoid upstream throttling.": "建议保持此值较高,以避免上游限流。", @@ -3661,8 +3784,10 @@ "Refresh Cache": "刷新缓存", "Refresh credential": "刷新凭据", "Refresh details": "刷新详情", + "Refresh every {{seconds}}s": "每 {{seconds}} 秒刷新", "Refresh failed": "刷新失败", "Refresh interval (minutes)": "刷新间隔 (分钟)", + "Refresh interval (seconds)": "刷新间隔(秒)", "Refresh Stats": "刷新统计", "Refreshing...": "刷新中...", "Refund": "退款", @@ -3685,6 +3810,7 @@ "Relying Party Display Name": "依赖方显示名称", "Relying Party ID": "依赖方 ID", "Remaining": "剩余", + "Remaining pool": "剩余奖池", "Remaining quota": "剩余配额", "Remaining Quota ({{currency}})": "剩余额度 ({{currency}})", "Remaining quota units": "剩余配额单位", @@ -3704,6 +3830,7 @@ "Remove node filter": "移除节点筛选", "Remove Passkey": "解绑 Passkey", "Remove Passkey?": "移除通行密钥?", + "Remove prize": "删除奖项", "Remove rule group": "移除规则组", "Remove string prefix": "去掉字符串前缀", "Remove string suffix": "去掉字符串后缀", @@ -3748,6 +3875,7 @@ "Request Header Field": "请求头字段", "Request Header Override": "请求头覆盖", "Request Header Overrides": "请求头覆盖", + "Request health by billing group for the latest 100 consume/error logs. Green bars show latency; red bars are failures. Badge uses overall success rate (≥95% normal, ≥80% warning, below 80% abnormal).": "按计费分组统计最近 100 条消费/错误日志。绿色竖条表示延迟,红色为失败。右上徽章按整体成功率:≥95% 正常,≥80% 警告,低于 80% 异常。", "Request ID": "请求 ID", "Request Limits": "请求限制", "Request Model": "请求模型", @@ -3999,6 +4127,7 @@ "Select a color": "选择颜色", "Select a group": "选择一个分组", "Select a group type": "选择分组类型", + "Select a model": "选择模型", "Select a model to edit pricing": "选择一个模型编辑定价", "Select a preset...": "选择一个预设...", "Select a product": "选择产品", @@ -4013,6 +4142,7 @@ "Select all (filtered)": "全选(筛选结果)", "Select all models": "选择所有模型", "Select All Visible": "全选当前", + "Select an icon": "选择图标", "Select an operation mode and enter the amount": "选择操作模式并输入金额", "Select announcement type": "选择公告类型", "Select at least one field to overwrite.": "请选择至少一个要覆盖的字段。", @@ -4046,6 +4176,7 @@ "Select models or add custom ones": "选择模型或添加自定义模型", "Select models to process. Unselected \"add\" models will be ignored.": "勾选要处理的模型,未勾选的「新增」模型将作为忽略处理。", "Select models to run batch tests.": "选择要运行批量测试的模型。", + "Select open mode": "选择打开方式", "Select or enter color value": "选择或输入颜色值", "Select or enter method identifier": "选择或输入支付方式标识", "Select or enter model name": "选择或输入模型名称", @@ -4071,6 +4202,7 @@ "Select theme preset": "选择主题预设", "Select time granularity": "选择时间粒度", "Select vendor": "选择供应商", + "Select visibility": "选择可见范围", "Selectable groups": "可选分组", "selected": "已选择", "Selected {{count}}": "已选 {{count}} 个", @@ -4127,6 +4259,7 @@ "Setting updated successfully": "设置更新成功", "Settings": "设置", "Settings & Preferences": "设置与偏好", + "Settings saved": "设置已保存", "Settings updated successfully": "设置更新成功", "Setup guide": "设置引导", "Setup guide complete": "设置引导已完成", @@ -4154,6 +4287,9 @@ "Showcase core capabilities with demo credentials and limited access.": "使用演示凭据和有限访问权限展示核心功能。", "Showing": "显示第", "showing •": "显示 •", + "Shown in the console sidebar. Maximum 100 characters.": "显示在控制台侧栏中,最多 100 个字符。", + "Shows a group-level request heartbeat chart under Extensions. Failed requests require ERROR_LOG_ENABLED.": "在「拓展」下按计费分组展示请求心跳图。失败点依赖 ERROR_LOG_ENABLED。", + "Shows Lucky Slot under Extensions. Draws are decided by the backend once per user per day.": "在拓展菜单显示幸运老虎机。结果由后端裁决,每用户每天仅可抽一次。", "Sidebar": "侧边栏", "Sidebar collapsed by default for new users": "默认情况下为新用户折叠侧边栏", "Sidebar modules": "侧边栏模块", @@ -4220,6 +4356,9 @@ "Special usable group rules make extra token groups visible to, or hide default ones from, users of a specific user group.": "特殊可用分组规则可以让特定用户分组的用户额外看到某些令牌分组,或对其屏蔽默认可选的令牌分组。", "Special visibility rules": "特殊可见性规则", "Spend limited": "消费受限", + "SPIN": "开始抽奖", + "SPINNING": "抽奖中", + "Spinning...": "转动中...", "SQLite stores all data in a single file. Make sure that file is persisted when running in containers.": "SQLite 将所有数据存储在单个文件中。在容器中运行时请确保该文件已持久化。", "SSL/TLS": "SSL/TLS", "SSRF Protection": "SSRF 保护", @@ -4380,6 +4519,7 @@ "Tag updated successfully": "标签更新成功", "Tag:": "标签:", "Tags": "标签", + "Tap the button below to draw": "点击下方按钮开始抽奖", "Take photo": "拍照", "Take screenshot": "截图", "Target Endpoint": "目标端点", @@ -4434,6 +4574,7 @@ "Text or array of texts to embed": "需要向量化的文本或文本数组", "Text Output": "文字输出", "Text to Video": "文生视频", + "Thanks": "谢谢惠顾", "The admin configured three groups and one special ratio rule:": "管理员配置了三个分组和一条特殊倍率规则:", "The admin wants vip users to pay even less when they use premium. That needs an override rule: in the override matrix, set the cell at row vip, column premium to 0.3.": "管理员希望 vip 用户使用 premium 时价格更低。这就需要一条覆盖规则:在覆盖矩阵中,把「行 vip、列 premium」的单元格填成 0.3。", "The administrator account is already initialized. You can keep your existing credentials and continue to the next step.": "管理员账户已初始化。您可以保留现有凭据并继续下一步。", @@ -4446,6 +4587,7 @@ "The bound Product powers wallet top-ups: when a user enters any amount, new-api runs the checkout against this single Pancake product and overrides the price per session — no need to pre-create $1 / $5 / $10 SKUs.": "已绑定产品用于钱包充值:当用户输入任意金额时,new-api 会基于这个单一 Pancake 产品发起结账,并按会话覆盖价格,无需预先创建 $1 / $5 / $10 的 SKU。", "The bound Store is the parent container for every Pancake product new-api creates from this admin — both the wallet top-up product and any subscription-plan products. One store is enough; pin a different one only if you genuinely run separate Pancake catalogs.": "已绑定店铺是 new-api 从此管理端创建的所有 Pancake 产品的父容器,包括钱包充值产品和订阅套餐产品。一个店铺通常足够;只有在确实运营多个 Pancake 目录时才需要绑定不同店铺。", "The deployment node that handled the requests": "处理请求的部署节点", + "The download will use the redemption name as the filename.": "兑换码将以文本文件的形式下载,文件名为兑换码的名称。", "The effective domain for Passkey registration. Must match the current domain or be its parent domain.": "用于 Passkey 注册的有效域。必须与当前域匹配或为其父域。", "The entered text does not match the required text.": "输入文本与要求文本不匹配。", "The environment (test vs production) is decided by the key you paste here — use the Test key while integrating, then swap to the Production key when going live.": "环境(测试或生产)由你在此粘贴的密钥决定。集成期间使用测试密钥,上线时再切换为生产密钥。", @@ -4458,6 +4600,7 @@ "The name displayed across the application": "在整个应用程序中显示的名称", "The public URL of your server, used for OAuth callbacks, webhooks, and other external integrations": "服务器的公开URL,用于OAuth回调、Webhook和其他外部集成", "The requested chat preset does not exist or has been removed.": "请求的聊天预设不存在或已被删除。", + "The requested page does not exist, is disabled, or has no URL configured.": "请求的页面不存在、已禁用,或尚未配置 URL。", "The reset request stays disabled until a credit is available.": "没有可用次数时,重置请求会保持禁用。", "The setup wizard will use this database during initialization.": "设置向导将在初始化过程中使用此数据库。", "The site is not available at the moment.": "该站点目前不可用。", @@ -4496,6 +4639,7 @@ "This channel type requires additional configuration": "此渠道类型需要填写额外配置", "This confirmation unlocks payment, redemption code, subscription plan, and invitation reward features. Please read the statements carefully.": "此确认会解锁支付、兑换码、订阅套餐和邀请奖励功能。请仔细阅读相关声明。", "This controls model request rate limiting. Web/API route throttling is configured by environment variables and may still return 429.": "此处仅控制模型请求速率限制。Web/API 路由限流由环境变量配置,仍可能返回 429。", + "This custom page will be removed from the list.": "此定制页面将从列表中移除。", "This data may be unreliable, use with caution": "此数据可能不可靠,请谨慎使用", "This device does not support Passkey": "此设备不支持 Passkey", "This device does not support Passkey verification.": "此设备不支持 Passkey 验证。", @@ -4515,6 +4659,7 @@ "This model is not available in any group, or no group pricing information is configured.": "此模型在任何分组中均不可用,或未配置分组定价信息。", "This month": "本月获得", "This page has not been created yet.": "此页面尚未创建。", + "This page opens in a new browser tab because the target site cannot be embedded.": "该页面会在新标签页打开,因为目标网站不允许被内嵌。", "This plan does not allow balance redemption": "该套餐不允许使用余额兑换", "This project must be used in compliance with the": "此项目的使用必须遵守", "This removes {{count}} failed models from this channel. This action cannot be undone.": "此操作将从该渠道移除 {{count}} 个测试失败的模型,且无法撤销。", @@ -4570,6 +4715,7 @@ "times": "次", "Timing": "耗时", "Tip": "提示", + "Title": "标题", "to access this resource.": "访问此资源。", "To Anthropic Messages": "转 Anthropic Messages", "to confirm": "以确认", @@ -4711,6 +4857,10 @@ "TTL (seconds)": "TTL(秒)", "Tune selection priority, testing, status handling, and request overrides.": "调整选择优先级、测试、状态处理和请求覆盖。", "Turnstile is enabled but site key is empty.": "Turnstile 已启用但站点密钥为空。", + "Turnstile site key is missing": "缺少 Turnstile 站点密钥", + "Turnstile failed to load. Check that this domain is allowed in Cloudflare Turnstile hostnames.": "人机验证加载失败。请确认当前域名已加入 Cloudflare Turnstile 的主机名白名单。", + "Turnstile script could not be loaded. Check network / ad blockers.": "无法加载人机验证脚本,请检查网络或广告拦截插件。", + "Human verification is required before you can continue.": "请先完成人机验证后再继续。", "Tutoring, learning aids, assessment": "辅导、学习辅助与测评", "Two-factor Authentication": "双重身份验证", "Two-Factor Authentication": "两步验证", @@ -4728,7 +4878,9 @@ "UI granularity only — data is still aggregated hourly": "仅 UI 粒度 — 数据仍按小时汇总", "Unable to estimate price for this deployment.": "无法为该部署估算价格。", "Unable to generate chat link. Please contact your administrator.": "无法生成聊天链接。请联系您的管理员。", + "Unable to load availability": "无法加载可用性数据", "Unable to load groups": "无法加载分组", + "Unable to load lottery": "无法加载抽奖", "Unable to load rankings": "无法加载排行榜", "Unable to load rankings data": "无法加载排行榜数据", "Unable to open chat": "无法打开聊天", @@ -4747,6 +4899,7 @@ "Unexpected release payload": "意外的版本数据格式", "Unified API Gateway for": "统一 API 网关,服务于", "Unique identifier for this group.": "此组的唯一标识符。", + "Unit is USD. Internally converted by QuotaPerUnit (default 500000 quota = $1). Doubled on Thursdays.": "单位为美元。内部按 QuotaPerUnit 换算(默认 500000 额度 = $1)。周四翻倍。", "Unit price (local currency / USD)": "单价(本地货币 / USD)", "Unit price (USD)": "单价 (USD)", "Unit price must be greater than 0": "单价必须大于 0", @@ -4854,10 +5007,11 @@ "Usage Logs": "使用日志", "Usage mode": "使用模式", "Usage-based": "基于使用量", - "USD": "USD", + "USD": "美元", "USD Exchange Rate": "美元汇率", "USD price per 1M input tokens.": "每 100 万输入 token 的美元价格。", "USD price per 1M tokens.": "每 100 万 token 的美元价格。", + "Use “Open in new tab” for sites that block iframe embedding (for example Taobao / Xianyu short links).": "若目标网站禁止被 iframe 嵌入(例如淘宝 / 闲鱼短链),请选择「新标签页打开」。", "Use +: to add a group, -: to remove a default selectable group, or no prefix to append a group.": "使用 +: 添加分组,使用 -: 移除默认可选分组,不加前缀则追加分组。", "Use a compatible browser or device with biometric authentication or a security key to register a Passkey.": "请使用支持生物识别认证或安全密钥的兼容浏览器或设备来注册通行密钥。", "Use a different stable value for each instance, then restart the service.": "每个实例使用不同且稳定的值,然后重启服务。", @@ -4936,6 +5090,7 @@ "Users call the model on the left. The platform forwards the request to the upstream model on the right.": "用户调用左侧的模型。平台将请求转发给右侧的上游模型。", "Users in {{group}}": "{{group}} 分组的用户", "Users must wait for a successful drawing before upscales or variations.": "用户必须等待成功的绘图完成,才能进行放大或变体。", + "Users must have a quota upper limit (used + remaining) strictly greater than this USD value. Use this to require recharge (e.g. 5 if signup gift is $5 and min top-up is $10). Set 0 to disable.": "用户的额度上限(已用 + 剩余)必须严格大于该美元值才可参与。可用于要求已充值(例如注册赠送 $5、最低充值 $10 时可设为 5)。设为 0 表示不限制。", "Users of vip, when billed as premium, pay ratio": "vip 分组的用户,按 premium 计费时,倍率用", "Users only see groups marked as user selectable. Non-selectable groups can still be assigned by administrators.": "用户只能看到标记为用户可选的分组。不可选分组仍可由管理员分配。", "uses": "使用次数", @@ -5010,6 +5165,7 @@ "Violation Marker": "违规标记", "vip": "vip", "VIP users with premium access": "拥有高级访问权限的 VIP 用户", + "Visibility": "可见范围", "Visible": "可见", "Vision": "视觉", "Vision, image / video, document chat": "视觉理解、图像 / 视频、文档对话", @@ -5139,6 +5295,7 @@ "You commit to using upstream APIs, accounts, keys, quotas, and service capabilities only within the scope of lawful authorization obtained from upstream service providers, model service providers, or relevant rights holders, and will not conduct unauthorized resale, trafficking, distribution, or other non-compliant commercialization.": "你承诺仅在从上游服务提供商、模型服务提供商或相关权利人处获得合法授权的范围内使用上游 API、账户、密钥、额度和服务能力,并不会进行未经授权的转售、倒卖、分发或其他不合规商业化行为。", "You do not have permission to edit sensitive channel settings.": "你没有权限编辑敏感渠道设置。", "You don't have necessary permission": "您没有必要的权限", + "You got {{name}} ({{delta}})": "抽中 {{name}}({{delta}})", "You have legally obtained authorization for the connected model APIs, accounts, keys, and quotas.": "你已合法取得所连接模型 API、账户、密钥和额度的授权。", "You have unsaved changes": "您有未保存的更改", "You have unsaved changes. Are you sure you want to leave?": "您有未保存的更改。确定要离开吗?", @@ -5152,6 +5309,7 @@ "Your account cannot edit sensitive channel settings.": "你的账号不能编辑敏感渠道设置。", "your AI integration?": "你的 AI 集成了吗?", "Your Azure OpenAI endpoint URL": "您的 Azure OpenAI 端点 URL", + "Your balance": "你的余额", "Your Bot Name": "您的机器人名称", "Your Cloudflare Account ID": "您的 Cloudflare 账户 ID", "Your Discord OAuth Client ID": "您的 Discord OAuth 客户端 ID", @@ -5159,6 +5317,7 @@ "Your GitHub OAuth Client ID": "您的 GitHub OAuth 客户端 ID", "Your GitHub OAuth Client Secret": "您的 GitHub OAuth 客户端密钥", "Your new backup codes are ready": "您的新备份代码已准备就绪", + "Your quota": "你的额度", "Your Referral Link": "您的推荐链接", "Your setup guide is collapsed so usage stays in focus.": "设置引导已收起,让用量信息保持在焦点位置。", "Your system access token for API authentication. Keep it secure and don't share it with others.": "您的系统访问令牌,用于 API 认证。请妥善保管,不要与他人分享。", diff --git a/web/default/src/i18n/static-keys.ts b/web/default/src/i18n/static-keys.ts index 78a8d77e965a..4850c501ebde 100644 --- a/web/default/src/i18n/static-keys.ts +++ b/web/default/src/i18n/static-keys.ts @@ -278,6 +278,20 @@ export const STATIC_I18N_KEYS = [ 'Opus Model', 'Enter model name', + // Connect tool wizard + 'Connect tool', + 'Pick a provider type, group, model, and client. We create an API key and open the tool for you.', + 'Provider type', + 'No provider types are available for your current groups.', + 'No groups available for this provider type', + 'Select a model', + 'A recommended model is selected automatically. You can change it.', + 'Configuration tool', + 'Configuring...', + 'Create and configure', + 'API key created. Opening the selected tool...', + 'If the app did not open, install the tool and use this API key manually:', + // User binding dialog 'Account Binding Management', 'Built-in', diff --git a/web/default/src/routeTree.gen.ts b/web/default/src/routeTree.gen.ts index e0add2a9b93d..4f9962fb5a7b 100644 --- a/web/default/src/routeTree.gen.ts +++ b/web/default/src/routeTree.gen.ts @@ -51,14 +51,18 @@ import { Route as AuthenticatedDashboardIndexRouteImport } from './routes/_authe import { Route as AuthenticatedChannelsIndexRouteImport } from './routes/_authenticated/channels/index' import { Route as AuthenticatedUsageLogsSectionRouteImport } from './routes/_authenticated/usage-logs/$section' import { Route as AuthenticatedModelsSectionRouteImport } from './routes/_authenticated/models/$section' +import { Route as AuthenticatedExtensionsLotteryRouteImport } from './routes/_authenticated/extensions/lottery' +import { Route as AuthenticatedExtensionsAvailabilityRouteImport } from './routes/_authenticated/extensions/availability' import { Route as AuthenticatedErrorsErrorRouteImport } from './routes/_authenticated/errors/$error' import { Route as AuthenticatedDashboardSectionRouteImport } from './routes/_authenticated/dashboard/$section' +import { Route as AuthenticatedCustomPagesPageIdRouteImport } from './routes/_authenticated/custom-pages/$pageId' import { Route as AuthenticatedChatChatIdRouteImport } from './routes/_authenticated/chat/$chatId' import { Route as authUserResetRouteImport } from './routes/(auth)/user/reset' import { Route as AuthenticatedSystemSettingsSiteIndexRouteImport } from './routes/_authenticated/system-settings/site/index' import { Route as AuthenticatedSystemSettingsSecurityIndexRouteImport } from './routes/_authenticated/system-settings/security/index' import { Route as AuthenticatedSystemSettingsOperationsIndexRouteImport } from './routes/_authenticated/system-settings/operations/index' import { Route as AuthenticatedSystemSettingsModelsIndexRouteImport } from './routes/_authenticated/system-settings/models/index' +import { Route as AuthenticatedSystemSettingsExtensionsIndexRouteImport } from './routes/_authenticated/system-settings/extensions/index' import { Route as AuthenticatedSystemSettingsContentIndexRouteImport } from './routes/_authenticated/system-settings/content/index' import { Route as AuthenticatedSystemSettingsBillingIndexRouteImport } from './routes/_authenticated/system-settings/billing/index' import { Route as AuthenticatedSystemSettingsAuthIndexRouteImport } from './routes/_authenticated/system-settings/auth/index' @@ -66,6 +70,7 @@ import { Route as AuthenticatedSystemSettingsSiteSectionRouteImport } from './ro import { Route as AuthenticatedSystemSettingsSecuritySectionRouteImport } from './routes/_authenticated/system-settings/security/$section' import { Route as AuthenticatedSystemSettingsOperationsSectionRouteImport } from './routes/_authenticated/system-settings/operations/$section' import { Route as AuthenticatedSystemSettingsModelsSectionRouteImport } from './routes/_authenticated/system-settings/models/$section' +import { Route as AuthenticatedSystemSettingsExtensionsSectionRouteImport } from './routes/_authenticated/system-settings/extensions/$section' import { Route as AuthenticatedSystemSettingsContentSectionRouteImport } from './routes/_authenticated/system-settings/content/$section' import { Route as AuthenticatedSystemSettingsBillingSectionRouteImport } from './routes/_authenticated/system-settings/billing/$section' import { Route as AuthenticatedSystemSettingsAuthSectionRouteImport } from './routes/_authenticated/system-settings/auth/$section' @@ -292,6 +297,18 @@ const AuthenticatedModelsSectionRoute = path: '/models/$section', getParentRoute: () => AuthenticatedRouteRoute, } as any) +const AuthenticatedExtensionsLotteryRoute = + AuthenticatedExtensionsLotteryRouteImport.update({ + id: '/extensions/lottery', + path: '/extensions/lottery', + getParentRoute: () => AuthenticatedRouteRoute, + } as any) +const AuthenticatedExtensionsAvailabilityRoute = + AuthenticatedExtensionsAvailabilityRouteImport.update({ + id: '/extensions/availability', + path: '/extensions/availability', + getParentRoute: () => AuthenticatedRouteRoute, + } as any) const AuthenticatedErrorsErrorRoute = AuthenticatedErrorsErrorRouteImport.update({ id: '/errors/$error', @@ -304,6 +321,12 @@ const AuthenticatedDashboardSectionRoute = path: '/dashboard/$section', getParentRoute: () => AuthenticatedRouteRoute, } as any) +const AuthenticatedCustomPagesPageIdRoute = + AuthenticatedCustomPagesPageIdRouteImport.update({ + id: '/custom-pages/$pageId', + path: '/custom-pages/$pageId', + getParentRoute: () => AuthenticatedRouteRoute, + } as any) const AuthenticatedChatChatIdRoute = AuthenticatedChatChatIdRouteImport.update({ id: '/chat/$chatId', path: '/chat/$chatId', @@ -338,6 +361,12 @@ const AuthenticatedSystemSettingsModelsIndexRoute = path: '/models/', getParentRoute: () => AuthenticatedSystemSettingsRouteRoute, } as any) +const AuthenticatedSystemSettingsExtensionsIndexRoute = + AuthenticatedSystemSettingsExtensionsIndexRouteImport.update({ + id: '/extensions/', + path: '/extensions/', + getParentRoute: () => AuthenticatedSystemSettingsRouteRoute, + } as any) const AuthenticatedSystemSettingsContentIndexRoute = AuthenticatedSystemSettingsContentIndexRouteImport.update({ id: '/content/', @@ -380,6 +409,12 @@ const AuthenticatedSystemSettingsModelsSectionRoute = path: '/models/$section', getParentRoute: () => AuthenticatedSystemSettingsRouteRoute, } as any) +const AuthenticatedSystemSettingsExtensionsSectionRoute = + AuthenticatedSystemSettingsExtensionsSectionRouteImport.update({ + id: '/extensions/$section', + path: '/extensions/$section', + getParentRoute: () => AuthenticatedSystemSettingsRouteRoute, + } as any) const AuthenticatedSystemSettingsContentSectionRoute = AuthenticatedSystemSettingsContentSectionRouteImport.update({ id: '/content/$section', @@ -426,8 +461,11 @@ export interface FileRoutesByFullPath { '/setup/': typeof SetupIndexRoute '/user/reset': typeof authUserResetRoute '/chat/$chatId': typeof AuthenticatedChatChatIdRoute + '/custom-pages/$pageId': typeof AuthenticatedCustomPagesPageIdRoute '/dashboard/$section': typeof AuthenticatedDashboardSectionRoute '/errors/$error': typeof AuthenticatedErrorsErrorRoute + '/extensions/availability': typeof AuthenticatedExtensionsAvailabilityRoute + '/extensions/lottery': typeof AuthenticatedExtensionsLotteryRoute '/models/$section': typeof AuthenticatedModelsSectionRoute '/usage-logs/$section': typeof AuthenticatedUsageLogsSectionRoute '/channels/': typeof AuthenticatedChannelsIndexRoute @@ -447,6 +485,7 @@ export interface FileRoutesByFullPath { '/system-settings/auth/$section': typeof AuthenticatedSystemSettingsAuthSectionRoute '/system-settings/billing/$section': typeof AuthenticatedSystemSettingsBillingSectionRoute '/system-settings/content/$section': typeof AuthenticatedSystemSettingsContentSectionRoute + '/system-settings/extensions/$section': typeof AuthenticatedSystemSettingsExtensionsSectionRoute '/system-settings/models/$section': typeof AuthenticatedSystemSettingsModelsSectionRoute '/system-settings/operations/$section': typeof AuthenticatedSystemSettingsOperationsSectionRoute '/system-settings/security/$section': typeof AuthenticatedSystemSettingsSecuritySectionRoute @@ -454,6 +493,7 @@ export interface FileRoutesByFullPath { '/system-settings/auth/': typeof AuthenticatedSystemSettingsAuthIndexRoute '/system-settings/billing/': typeof AuthenticatedSystemSettingsBillingIndexRoute '/system-settings/content/': typeof AuthenticatedSystemSettingsContentIndexRoute + '/system-settings/extensions/': typeof AuthenticatedSystemSettingsExtensionsIndexRoute '/system-settings/models/': typeof AuthenticatedSystemSettingsModelsIndexRoute '/system-settings/operations/': typeof AuthenticatedSystemSettingsOperationsIndexRoute '/system-settings/security/': typeof AuthenticatedSystemSettingsSecurityIndexRoute @@ -485,8 +525,11 @@ export interface FileRoutesByTo { '/setup': typeof SetupIndexRoute '/user/reset': typeof authUserResetRoute '/chat/$chatId': typeof AuthenticatedChatChatIdRoute + '/custom-pages/$pageId': typeof AuthenticatedCustomPagesPageIdRoute '/dashboard/$section': typeof AuthenticatedDashboardSectionRoute '/errors/$error': typeof AuthenticatedErrorsErrorRoute + '/extensions/availability': typeof AuthenticatedExtensionsAvailabilityRoute + '/extensions/lottery': typeof AuthenticatedExtensionsLotteryRoute '/models/$section': typeof AuthenticatedModelsSectionRoute '/usage-logs/$section': typeof AuthenticatedUsageLogsSectionRoute '/channels': typeof AuthenticatedChannelsIndexRoute @@ -506,6 +549,7 @@ export interface FileRoutesByTo { '/system-settings/auth/$section': typeof AuthenticatedSystemSettingsAuthSectionRoute '/system-settings/billing/$section': typeof AuthenticatedSystemSettingsBillingSectionRoute '/system-settings/content/$section': typeof AuthenticatedSystemSettingsContentSectionRoute + '/system-settings/extensions/$section': typeof AuthenticatedSystemSettingsExtensionsSectionRoute '/system-settings/models/$section': typeof AuthenticatedSystemSettingsModelsSectionRoute '/system-settings/operations/$section': typeof AuthenticatedSystemSettingsOperationsSectionRoute '/system-settings/security/$section': typeof AuthenticatedSystemSettingsSecuritySectionRoute @@ -513,6 +557,7 @@ export interface FileRoutesByTo { '/system-settings/auth': typeof AuthenticatedSystemSettingsAuthIndexRoute '/system-settings/billing': typeof AuthenticatedSystemSettingsBillingIndexRoute '/system-settings/content': typeof AuthenticatedSystemSettingsContentIndexRoute + '/system-settings/extensions': typeof AuthenticatedSystemSettingsExtensionsIndexRoute '/system-settings/models': typeof AuthenticatedSystemSettingsModelsIndexRoute '/system-settings/operations': typeof AuthenticatedSystemSettingsOperationsIndexRoute '/system-settings/security': typeof AuthenticatedSystemSettingsSecurityIndexRoute @@ -548,8 +593,11 @@ export interface FileRoutesById { '/setup/': typeof SetupIndexRoute '/(auth)/user/reset': typeof authUserResetRoute '/_authenticated/chat/$chatId': typeof AuthenticatedChatChatIdRoute + '/_authenticated/custom-pages/$pageId': typeof AuthenticatedCustomPagesPageIdRoute '/_authenticated/dashboard/$section': typeof AuthenticatedDashboardSectionRoute '/_authenticated/errors/$error': typeof AuthenticatedErrorsErrorRoute + '/_authenticated/extensions/availability': typeof AuthenticatedExtensionsAvailabilityRoute + '/_authenticated/extensions/lottery': typeof AuthenticatedExtensionsLotteryRoute '/_authenticated/models/$section': typeof AuthenticatedModelsSectionRoute '/_authenticated/usage-logs/$section': typeof AuthenticatedUsageLogsSectionRoute '/_authenticated/channels/': typeof AuthenticatedChannelsIndexRoute @@ -569,6 +617,7 @@ export interface FileRoutesById { '/_authenticated/system-settings/auth/$section': typeof AuthenticatedSystemSettingsAuthSectionRoute '/_authenticated/system-settings/billing/$section': typeof AuthenticatedSystemSettingsBillingSectionRoute '/_authenticated/system-settings/content/$section': typeof AuthenticatedSystemSettingsContentSectionRoute + '/_authenticated/system-settings/extensions/$section': typeof AuthenticatedSystemSettingsExtensionsSectionRoute '/_authenticated/system-settings/models/$section': typeof AuthenticatedSystemSettingsModelsSectionRoute '/_authenticated/system-settings/operations/$section': typeof AuthenticatedSystemSettingsOperationsSectionRoute '/_authenticated/system-settings/security/$section': typeof AuthenticatedSystemSettingsSecuritySectionRoute @@ -576,6 +625,7 @@ export interface FileRoutesById { '/_authenticated/system-settings/auth/': typeof AuthenticatedSystemSettingsAuthIndexRoute '/_authenticated/system-settings/billing/': typeof AuthenticatedSystemSettingsBillingIndexRoute '/_authenticated/system-settings/content/': typeof AuthenticatedSystemSettingsContentIndexRoute + '/_authenticated/system-settings/extensions/': typeof AuthenticatedSystemSettingsExtensionsIndexRoute '/_authenticated/system-settings/models/': typeof AuthenticatedSystemSettingsModelsIndexRoute '/_authenticated/system-settings/operations/': typeof AuthenticatedSystemSettingsOperationsIndexRoute '/_authenticated/system-settings/security/': typeof AuthenticatedSystemSettingsSecurityIndexRoute @@ -610,8 +660,11 @@ export interface FileRouteTypes { | '/setup/' | '/user/reset' | '/chat/$chatId' + | '/custom-pages/$pageId' | '/dashboard/$section' | '/errors/$error' + | '/extensions/availability' + | '/extensions/lottery' | '/models/$section' | '/usage-logs/$section' | '/channels/' @@ -631,6 +684,7 @@ export interface FileRouteTypes { | '/system-settings/auth/$section' | '/system-settings/billing/$section' | '/system-settings/content/$section' + | '/system-settings/extensions/$section' | '/system-settings/models/$section' | '/system-settings/operations/$section' | '/system-settings/security/$section' @@ -638,6 +692,7 @@ export interface FileRouteTypes { | '/system-settings/auth/' | '/system-settings/billing/' | '/system-settings/content/' + | '/system-settings/extensions/' | '/system-settings/models/' | '/system-settings/operations/' | '/system-settings/security/' @@ -669,8 +724,11 @@ export interface FileRouteTypes { | '/setup' | '/user/reset' | '/chat/$chatId' + | '/custom-pages/$pageId' | '/dashboard/$section' | '/errors/$error' + | '/extensions/availability' + | '/extensions/lottery' | '/models/$section' | '/usage-logs/$section' | '/channels' @@ -690,6 +748,7 @@ export interface FileRouteTypes { | '/system-settings/auth/$section' | '/system-settings/billing/$section' | '/system-settings/content/$section' + | '/system-settings/extensions/$section' | '/system-settings/models/$section' | '/system-settings/operations/$section' | '/system-settings/security/$section' @@ -697,6 +756,7 @@ export interface FileRouteTypes { | '/system-settings/auth' | '/system-settings/billing' | '/system-settings/content' + | '/system-settings/extensions' | '/system-settings/models' | '/system-settings/operations' | '/system-settings/security' @@ -731,8 +791,11 @@ export interface FileRouteTypes { | '/setup/' | '/(auth)/user/reset' | '/_authenticated/chat/$chatId' + | '/_authenticated/custom-pages/$pageId' | '/_authenticated/dashboard/$section' | '/_authenticated/errors/$error' + | '/_authenticated/extensions/availability' + | '/_authenticated/extensions/lottery' | '/_authenticated/models/$section' | '/_authenticated/usage-logs/$section' | '/_authenticated/channels/' @@ -752,6 +815,7 @@ export interface FileRouteTypes { | '/_authenticated/system-settings/auth/$section' | '/_authenticated/system-settings/billing/$section' | '/_authenticated/system-settings/content/$section' + | '/_authenticated/system-settings/extensions/$section' | '/_authenticated/system-settings/models/$section' | '/_authenticated/system-settings/operations/$section' | '/_authenticated/system-settings/security/$section' @@ -759,6 +823,7 @@ export interface FileRouteTypes { | '/_authenticated/system-settings/auth/' | '/_authenticated/system-settings/billing/' | '/_authenticated/system-settings/content/' + | '/_authenticated/system-settings/extensions/' | '/_authenticated/system-settings/models/' | '/_authenticated/system-settings/operations/' | '/_authenticated/system-settings/security/' @@ -1082,6 +1147,20 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof AuthenticatedModelsSectionRouteImport parentRoute: typeof AuthenticatedRouteRoute } + '/_authenticated/extensions/lottery': { + id: '/_authenticated/extensions/lottery' + path: '/extensions/lottery' + fullPath: '/extensions/lottery' + preLoaderRoute: typeof AuthenticatedExtensionsLotteryRouteImport + parentRoute: typeof AuthenticatedRouteRoute + } + '/_authenticated/extensions/availability': { + id: '/_authenticated/extensions/availability' + path: '/extensions/availability' + fullPath: '/extensions/availability' + preLoaderRoute: typeof AuthenticatedExtensionsAvailabilityRouteImport + parentRoute: typeof AuthenticatedRouteRoute + } '/_authenticated/errors/$error': { id: '/_authenticated/errors/$error' path: '/errors/$error' @@ -1096,6 +1175,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof AuthenticatedDashboardSectionRouteImport parentRoute: typeof AuthenticatedRouteRoute } + '/_authenticated/custom-pages/$pageId': { + id: '/_authenticated/custom-pages/$pageId' + path: '/custom-pages/$pageId' + fullPath: '/custom-pages/$pageId' + preLoaderRoute: typeof AuthenticatedCustomPagesPageIdRouteImport + parentRoute: typeof AuthenticatedRouteRoute + } '/_authenticated/chat/$chatId': { id: '/_authenticated/chat/$chatId' path: '/chat/$chatId' @@ -1138,6 +1224,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof AuthenticatedSystemSettingsModelsIndexRouteImport parentRoute: typeof AuthenticatedSystemSettingsRouteRoute } + '/_authenticated/system-settings/extensions/': { + id: '/_authenticated/system-settings/extensions/' + path: '/extensions' + fullPath: '/system-settings/extensions/' + preLoaderRoute: typeof AuthenticatedSystemSettingsExtensionsIndexRouteImport + parentRoute: typeof AuthenticatedSystemSettingsRouteRoute + } '/_authenticated/system-settings/content/': { id: '/_authenticated/system-settings/content/' path: '/content' @@ -1187,6 +1280,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof AuthenticatedSystemSettingsModelsSectionRouteImport parentRoute: typeof AuthenticatedSystemSettingsRouteRoute } + '/_authenticated/system-settings/extensions/$section': { + id: '/_authenticated/system-settings/extensions/$section' + path: '/extensions/$section' + fullPath: '/system-settings/extensions/$section' + preLoaderRoute: typeof AuthenticatedSystemSettingsExtensionsSectionRouteImport + parentRoute: typeof AuthenticatedSystemSettingsRouteRoute + } '/_authenticated/system-settings/content/$section': { id: '/_authenticated/system-settings/content/$section' path: '/content/$section' @@ -1242,6 +1342,7 @@ interface AuthenticatedSystemSettingsRouteRouteChildren { AuthenticatedSystemSettingsAuthSectionRoute: typeof AuthenticatedSystemSettingsAuthSectionRoute AuthenticatedSystemSettingsBillingSectionRoute: typeof AuthenticatedSystemSettingsBillingSectionRoute AuthenticatedSystemSettingsContentSectionRoute: typeof AuthenticatedSystemSettingsContentSectionRoute + AuthenticatedSystemSettingsExtensionsSectionRoute: typeof AuthenticatedSystemSettingsExtensionsSectionRoute AuthenticatedSystemSettingsModelsSectionRoute: typeof AuthenticatedSystemSettingsModelsSectionRoute AuthenticatedSystemSettingsOperationsSectionRoute: typeof AuthenticatedSystemSettingsOperationsSectionRoute AuthenticatedSystemSettingsSecuritySectionRoute: typeof AuthenticatedSystemSettingsSecuritySectionRoute @@ -1249,6 +1350,7 @@ interface AuthenticatedSystemSettingsRouteRouteChildren { AuthenticatedSystemSettingsAuthIndexRoute: typeof AuthenticatedSystemSettingsAuthIndexRoute AuthenticatedSystemSettingsBillingIndexRoute: typeof AuthenticatedSystemSettingsBillingIndexRoute AuthenticatedSystemSettingsContentIndexRoute: typeof AuthenticatedSystemSettingsContentIndexRoute + AuthenticatedSystemSettingsExtensionsIndexRoute: typeof AuthenticatedSystemSettingsExtensionsIndexRoute AuthenticatedSystemSettingsModelsIndexRoute: typeof AuthenticatedSystemSettingsModelsIndexRoute AuthenticatedSystemSettingsOperationsIndexRoute: typeof AuthenticatedSystemSettingsOperationsIndexRoute AuthenticatedSystemSettingsSecurityIndexRoute: typeof AuthenticatedSystemSettingsSecurityIndexRoute @@ -1265,6 +1367,8 @@ const AuthenticatedSystemSettingsRouteRouteChildren: AuthenticatedSystemSettings AuthenticatedSystemSettingsBillingSectionRoute, AuthenticatedSystemSettingsContentSectionRoute: AuthenticatedSystemSettingsContentSectionRoute, + AuthenticatedSystemSettingsExtensionsSectionRoute: + AuthenticatedSystemSettingsExtensionsSectionRoute, AuthenticatedSystemSettingsModelsSectionRoute: AuthenticatedSystemSettingsModelsSectionRoute, AuthenticatedSystemSettingsOperationsSectionRoute: @@ -1279,6 +1383,8 @@ const AuthenticatedSystemSettingsRouteRouteChildren: AuthenticatedSystemSettings AuthenticatedSystemSettingsBillingIndexRoute, AuthenticatedSystemSettingsContentIndexRoute: AuthenticatedSystemSettingsContentIndexRoute, + AuthenticatedSystemSettingsExtensionsIndexRoute: + AuthenticatedSystemSettingsExtensionsIndexRoute, AuthenticatedSystemSettingsModelsIndexRoute: AuthenticatedSystemSettingsModelsIndexRoute, AuthenticatedSystemSettingsOperationsIndexRoute: @@ -1298,8 +1404,11 @@ interface AuthenticatedRouteRouteChildren { AuthenticatedSystemSettingsRouteRoute: typeof AuthenticatedSystemSettingsRouteRouteWithChildren AuthenticatedChat2linkRoute: typeof AuthenticatedChat2linkRoute AuthenticatedChatChatIdRoute: typeof AuthenticatedChatChatIdRoute + AuthenticatedCustomPagesPageIdRoute: typeof AuthenticatedCustomPagesPageIdRoute AuthenticatedDashboardSectionRoute: typeof AuthenticatedDashboardSectionRoute AuthenticatedErrorsErrorRoute: typeof AuthenticatedErrorsErrorRoute + AuthenticatedExtensionsAvailabilityRoute: typeof AuthenticatedExtensionsAvailabilityRoute + AuthenticatedExtensionsLotteryRoute: typeof AuthenticatedExtensionsLotteryRoute AuthenticatedModelsSectionRoute: typeof AuthenticatedModelsSectionRoute AuthenticatedUsageLogsSectionRoute: typeof AuthenticatedUsageLogsSectionRoute AuthenticatedChannelsIndexRoute: typeof AuthenticatedChannelsIndexRoute @@ -1321,8 +1430,12 @@ const AuthenticatedRouteRouteChildren: AuthenticatedRouteRouteChildren = { AuthenticatedSystemSettingsRouteRouteWithChildren, AuthenticatedChat2linkRoute: AuthenticatedChat2linkRoute, AuthenticatedChatChatIdRoute: AuthenticatedChatChatIdRoute, + AuthenticatedCustomPagesPageIdRoute: AuthenticatedCustomPagesPageIdRoute, AuthenticatedDashboardSectionRoute: AuthenticatedDashboardSectionRoute, AuthenticatedErrorsErrorRoute: AuthenticatedErrorsErrorRoute, + AuthenticatedExtensionsAvailabilityRoute: + AuthenticatedExtensionsAvailabilityRoute, + AuthenticatedExtensionsLotteryRoute: AuthenticatedExtensionsLotteryRoute, AuthenticatedModelsSectionRoute: AuthenticatedModelsSectionRoute, AuthenticatedUsageLogsSectionRoute: AuthenticatedUsageLogsSectionRoute, AuthenticatedChannelsIndexRoute: AuthenticatedChannelsIndexRoute, diff --git a/web/default/src/routes/_authenticated/custom-pages/$pageId.tsx b/web/default/src/routes/_authenticated/custom-pages/$pageId.tsx new file mode 100644 index 000000000000..38de7c22156e --- /dev/null +++ b/web/default/src/routes/_authenticated/custom-pages/$pageId.tsx @@ -0,0 +1,141 @@ +/* +Copyright (C) 2023-2026 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, + 70|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 . + +For commercial licensing, please contact support@quantumnous.com +*/ +import { Link, createFileRoute } from '@tanstack/react-router' +import { ExternalLink, MessageCircleWarning } from 'lucide-react' +import { useEffect, useMemo, useRef } from 'react' +import { useTranslation } from 'react-i18next' + +import { Button } from '@/components/ui/button' +import { + resolveCustomPageOpenMode, + type CustomPageStatusItem, +} from '@/features/system-settings/extensions/constants' +import { useStatus } from '@/hooks/use-status' + +export const Route = createFileRoute('/_authenticated/custom-pages/$pageId')({ + component: CustomPageRouteComponent, +}) + +function CustomPageRouteComponent() { + const { t } = useTranslation() + const { pageId } = Route.useParams() + const { status, loading } = useStatus() + const autoOpenedRef = useRef(false) + + const page = useMemo(() => { + const pages = (status?.custom_pages ?? + status?.data?.custom_pages) as CustomPageStatusItem[] | undefined + if (!Array.isArray(pages)) return undefined + return pages.find((item) => item.id === pageId) + }, [pageId, status]) + + const openMode = resolveCustomPageOpenMode(page?.open_mode) + + useEffect(() => { + autoOpenedRef.current = false + }, [page?.id, page?.url, openMode]) + + useEffect(() => { + if (!page?.url || openMode !== 'external' || autoOpenedRef.current) { + return + } + autoOpenedRef.current = true + window.open(page.url, '_blank', 'noopener,noreferrer') + }, [openMode, page?.url]) + + if (loading && !page) { + return ( +
+

{t('Loading...')}

+
+ ) + } + + if (!page || !page.url) { + return ( +
+ +
+

+ {t('Custom page not found')} +

+

+ {t( + 'The requested page does not exist, is disabled, or has no URL configured.' + )} +

+
+ +
+ ) + } + + if (openMode === 'external') { + return ( +
+ +
+

{page.title}

+

+ {t( + 'This page opens in a new browser tab because the target site cannot be embedded.' + )} +

+
+
+ + +
+
+ ) + } + + return ( +
+
+

{page.title}

+ +
+