Commit 9b93d61b by Seefs Committed by GitHub

feat(subscription): add admin quota reset actions (#5952)

* feat(subscription): add admin quota reset actions

* fix(subscription): keep quota reset in plan row actions

* refactor(subscription): move user subscription actions into menu
parent d1abf78e
...@@ -46,6 +46,9 @@ var auditContentTemplates = map[string]string{ ...@@ -46,6 +46,9 @@ var auditContentTemplates = map[string]string{
"channel.upstream_apply_all": "Applied upstream model changes to ${count} channels", "channel.upstream_apply_all": "Applied upstream model changes to ${count} channels",
"redemption.create": "Created ${count} redemption codes named ${name} (${quota} each)", "redemption.create": "Created ${count} redemption codes named ${name} (${quota} each)",
"subscription.plan_reset": "Reset active subscriptions for plan ${plan_id}",
"subscription.user_plan_reset": "Reset active plan ${plan_id} subscriptions for user ${target_user_id}",
} }
// auditContentEN 按 action 模板渲染英文兜底文本;未登记的 action 退回 action 本身。 // auditContentEN 按 action 模板渲染英文兜底文本;未登记的 action 退回 action 本身。
......
package controller package controller
import ( import (
"fmt"
"strconv" "strconv"
"strings" "strings"
...@@ -394,6 +395,28 @@ type AdminCreateUserSubscriptionRequest struct { ...@@ -394,6 +395,28 @@ type AdminCreateUserSubscriptionRequest struct {
PlanId int `json:"plan_id"` PlanId int `json:"plan_id"`
} }
type AdminResetSubscriptionRequest struct {
PlanId int `json:"plan_id"`
AdvanceResetTime *bool `json:"advance_reset_time"`
}
func resolveAdvanceResetTime(value *bool) bool {
if value == nil {
return true
}
return *value
}
func recordSubscriptionResetUserLogs(result *model.SubscriptionResetResult, adminInfo map[string]interface{}) {
if result == nil || result.ResetCount == 0 {
return
}
content := fmt.Sprintf("管理员重置订阅套餐 %s(ID: %d)额度", result.PlanTitle, result.PlanId)
for _, userId := range result.AffectedUserIds {
model.RecordLogWithAdminInfo(userId, model.LogTypeManage, content, adminInfo)
}
}
// AdminCreateUserSubscription creates a new user subscription from a plan (no payment). // AdminCreateUserSubscription creates a new user subscription from a plan (no payment).
func AdminCreateUserSubscription(c *gin.Context) { func AdminCreateUserSubscription(c *gin.Context) {
if !requirePaymentCompliance(c) { if !requirePaymentCompliance(c) {
...@@ -422,6 +445,69 @@ func AdminCreateUserSubscription(c *gin.Context) { ...@@ -422,6 +445,69 @@ func AdminCreateUserSubscription(c *gin.Context) {
common.ApiSuccess(c, nil) common.ApiSuccess(c, nil)
} }
func AdminResetUserSubscriptionsByPlan(c *gin.Context) {
userId, _ := strconv.Atoi(c.Param("id"))
if userId <= 0 {
common.ApiErrorMsg(c, "无效的用户ID")
return
}
var req AdminResetSubscriptionRequest
if err := c.ShouldBindJSON(&req); err != nil {
common.ApiErrorMsg(c, "参数错误")
return
}
if req.PlanId <= 0 {
common.ApiErrorMsg(c, "参数错误")
return
}
advanceResetTime := resolveAdvanceResetTime(req.AdvanceResetTime)
result, err := model.AdminResetUserSubscriptionsByPlan(userId, req.PlanId, advanceResetTime)
if err != nil {
common.ApiError(c, err)
return
}
recordSubscriptionResetUserLogs(result, auditOperatorInfo(c))
recordManageAuditFor(c, userId, "subscription.user_plan_reset", map[string]interface{}{
"target_user_id": userId,
"plan_id": result.PlanId,
"plan_title": result.PlanTitle,
"reset_count": result.ResetCount,
"user_count": result.UserCount,
"advance_reset_time": result.AdvanceResetTime,
})
common.ApiSuccess(c, result)
}
func AdminResetPlanSubscriptions(c *gin.Context) {
planId, _ := strconv.Atoi(c.Param("id"))
if planId <= 0 {
common.ApiErrorMsg(c, "无效的ID")
return
}
var req AdminResetSubscriptionRequest
if err := c.ShouldBindJSON(&req); err != nil {
common.ApiErrorMsg(c, "参数错误")
return
}
advanceResetTime := resolveAdvanceResetTime(req.AdvanceResetTime)
result, err := model.AdminResetPlanSubscriptions(planId, advanceResetTime)
if err != nil {
common.ApiError(c, err)
return
}
recordSubscriptionResetUserLogs(result, auditOperatorInfo(c))
common.SysLog(fmt.Sprintf("admin reset subscription plan %d quota: reset_count=%d user_count=%d advance_reset_time=%t",
result.PlanId, result.ResetCount, result.UserCount, result.AdvanceResetTime))
recordManageAudit(c, "subscription.plan_reset", map[string]interface{}{
"plan_id": result.PlanId,
"plan_title": result.PlanTitle,
"reset_count": result.ResetCount,
"user_count": result.UserCount,
"advance_reset_time": result.AdvanceResetTime,
})
common.ApiSuccess(c, result)
}
// AdminInvalidateUserSubscription cancels a user subscription immediately. // AdminInvalidateUserSubscription cancels a user subscription immediately.
func AdminInvalidateUserSubscription(c *gin.Context) { func AdminInvalidateUserSubscription(c *gin.Context) {
subId, _ := strconv.Atoi(c.Param("id")) subId, _ := strconv.Atoi(c.Param("id"))
......
...@@ -296,6 +296,16 @@ type SubscriptionSummary struct { ...@@ -296,6 +296,16 @@ type SubscriptionSummary struct {
Subscription *UserSubscription `json:"subscription"` Subscription *UserSubscription `json:"subscription"`
} }
type SubscriptionResetResult struct {
PlanId int `json:"plan_id"`
MatchedCount int `json:"matched_count"`
ResetCount int `json:"reset_count"`
UserCount int `json:"user_count"`
AdvanceResetTime bool `json:"advance_reset_time"`
PlanTitle string `json:"-"`
AffectedUserIds []int `json:"-"`
}
func calcPlanEndTime(start time.Time, plan *SubscriptionPlan) (int64, error) { func calcPlanEndTime(start time.Time, plan *SubscriptionPlan) (int64, error) {
if plan == nil { if plan == nil {
return 0, errors.New("plan is nil") return 0, errors.New("plan is nil")
...@@ -974,6 +984,125 @@ func AdminDeleteUserSubscription(userSubscriptionId int) (string, error) { ...@@ -974,6 +984,125 @@ func AdminDeleteUserSubscription(userSubscriptionId int) (string, error) {
return "", nil return "", nil
} }
func resetUserSubscriptionTx(tx *gorm.DB, sub *UserSubscription, plan *SubscriptionPlan, now int64, advanceResetTime bool) error {
if tx == nil || sub == nil || plan == nil {
return errors.New("invalid reset args")
}
sub.AmountUsed = 0
if advanceResetTime {
nextReset := calcNextResetTime(time.Unix(now, 0), plan, sub.EndTime)
sub.NextResetTime = nextReset
if nextReset > 0 {
sub.LastResetTime = now
} else {
sub.LastResetTime = 0
}
}
return tx.Save(sub).Error
}
func buildSubscriptionResetResult(plan *SubscriptionPlan, subs []UserSubscription, advanceResetTime bool) *SubscriptionResetResult {
userIds := make([]int, 0, len(subs))
seenUsers := make(map[int]struct{}, len(subs))
for _, sub := range subs {
if _, ok := seenUsers[sub.UserId]; ok {
continue
}
seenUsers[sub.UserId] = struct{}{}
userIds = append(userIds, sub.UserId)
}
return &SubscriptionResetResult{
PlanId: plan.Id,
MatchedCount: len(subs),
ResetCount: len(subs),
UserCount: len(userIds),
AdvanceResetTime: advanceResetTime,
PlanTitle: plan.Title,
AffectedUserIds: userIds,
}
}
func adminResetUserSubscriptionsByPlanTx(tx *gorm.DB, userId int, plan *SubscriptionPlan, now int64, advanceResetTime bool) (*SubscriptionResetResult, error) {
if tx == nil || plan == nil {
return nil, errors.New("invalid reset args")
}
var subs []UserSubscription
if err := tx.Set("gorm:query_option", "FOR UPDATE").
Where("user_id = ? AND plan_id = ? AND status = ? AND end_time > ?", userId, plan.Id, "active", now).
Order("end_time asc, id asc").
Find(&subs).Error; err != nil {
return nil, err
}
if len(subs) == 0 {
return nil, errors.New("该用户没有有效的此套餐订阅")
}
for i := range subs {
if err := resetUserSubscriptionTx(tx, &subs[i], plan, now, advanceResetTime); err != nil {
return nil, err
}
}
return buildSubscriptionResetResult(plan, subs, advanceResetTime), nil
}
func adminResetPlanSubscriptionsTx(tx *gorm.DB, plan *SubscriptionPlan, now int64, advanceResetTime bool) (*SubscriptionResetResult, error) {
if tx == nil || plan == nil {
return nil, errors.New("invalid reset args")
}
var subs []UserSubscription
if err := tx.Set("gorm:query_option", "FOR UPDATE").
Where("plan_id = ? AND status = ? AND end_time > ?", plan.Id, "active", now).
Order("user_id asc, end_time asc, id asc").
Find(&subs).Error; err != nil {
return nil, err
}
for i := range subs {
if err := resetUserSubscriptionTx(tx, &subs[i], plan, now, advanceResetTime); err != nil {
return nil, err
}
}
return buildSubscriptionResetResult(plan, subs, advanceResetTime), nil
}
func AdminResetUserSubscriptionsByPlan(userId int, planId int, advanceResetTime bool) (*SubscriptionResetResult, error) {
if userId <= 0 || planId <= 0 {
return nil, errors.New("invalid userId or planId")
}
var result *SubscriptionResetResult
now := GetDBTimestamp()
err := DB.Transaction(func(tx *gorm.DB) error {
plan, err := getSubscriptionPlanByIdTx(tx, planId)
if err != nil {
return err
}
result, err = adminResetUserSubscriptionsByPlanTx(tx, userId, plan, now, advanceResetTime)
return err
})
if err != nil {
return nil, err
}
return result, nil
}
func AdminResetPlanSubscriptions(planId int, advanceResetTime bool) (*SubscriptionResetResult, error) {
if planId <= 0 {
return nil, errors.New("invalid planId")
}
var result *SubscriptionResetResult
now := GetDBTimestamp()
err := DB.Transaction(func(tx *gorm.DB) error {
plan, err := getSubscriptionPlanByIdTx(tx, planId)
if err != nil {
return err
}
result, err = adminResetPlanSubscriptionsTx(tx, plan, now, advanceResetTime)
return err
})
if err != nil {
return nil, err
}
return result, nil
}
type SubscriptionPreConsumeResult struct { type SubscriptionPreConsumeResult struct {
UserSubscriptionId int UserSubscriptionId int
PreConsumed int64 PreConsumed int64
......
package model
import (
"strings"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func seedSubscriptionResetPlan(t *testing.T, plan *SubscriptionPlan) {
t.Helper()
require.NoError(t, DB.Create(plan).Error)
}
func seedSubscriptionResetSub(t *testing.T, sub *UserSubscription) {
t.Helper()
require.NoError(t, DB.Create(sub).Error)
}
func getSubscriptionResetSub(t *testing.T, id int) UserSubscription {
t.Helper()
var sub UserSubscription
require.NoError(t, DB.Where("id = ?", id).First(&sub).Error)
return sub
}
func TestAdminResetUserSubscriptionsByPlanResetsAllActiveMatchesAndAdvancesTime(t *testing.T) {
truncateTables(t)
now := GetDBTimestamp()
plan := &SubscriptionPlan{
Id: 9101,
Title: "Pro",
PriceAmount: 10,
DurationUnit: SubscriptionDurationMonth,
DurationValue: 1,
TotalAmount: 1000,
QuotaResetPeriod: SubscriptionResetDaily,
}
otherPlan := &SubscriptionPlan{
Id: 9102,
Title: "Basic",
PriceAmount: 1,
DurationUnit: SubscriptionDurationMonth,
DurationValue: 1,
TotalAmount: 100,
QuotaResetPeriod: SubscriptionResetDaily,
}
seedSubscriptionResetPlan(t, plan)
seedSubscriptionResetPlan(t, otherPlan)
activeEnd := now + 30*24*3600
expiredEnd := now - 1
seedSubscriptionResetSub(t, &UserSubscription{Id: 9201, UserId: 101, PlanId: plan.Id, AmountTotal: 1000, AmountUsed: 300, StartTime: now - 3600, EndTime: activeEnd, Status: "active", LastResetTime: now - 3600, NextResetTime: now + 120})
seedSubscriptionResetSub(t, &UserSubscription{Id: 9202, UserId: 101, PlanId: plan.Id, AmountTotal: 1000, AmountUsed: 500, StartTime: now - 3600, EndTime: activeEnd, Status: "active", LastResetTime: now - 3600, NextResetTime: now + 120})
seedSubscriptionResetSub(t, &UserSubscription{Id: 9203, UserId: 101, PlanId: otherPlan.Id, AmountTotal: 100, AmountUsed: 60, StartTime: now - 3600, EndTime: activeEnd, Status: "active", LastResetTime: now - 3600, NextResetTime: now + 120})
seedSubscriptionResetSub(t, &UserSubscription{Id: 9204, UserId: 101, PlanId: plan.Id, AmountTotal: 1000, AmountUsed: 700, StartTime: now - 7200, EndTime: expiredEnd, Status: "active", LastResetTime: now - 3600, NextResetTime: now - 10})
seedSubscriptionResetSub(t, &UserSubscription{Id: 9205, UserId: 102, PlanId: plan.Id, AmountTotal: 1000, AmountUsed: 800, StartTime: now - 3600, EndTime: activeEnd, Status: "active", LastResetTime: now - 3600, NextResetTime: now + 120})
seedSubscriptionResetSub(t, &UserSubscription{Id: 9206, UserId: 101, PlanId: plan.Id, AmountTotal: 1000, AmountUsed: 900, StartTime: now - 3600, EndTime: activeEnd, Status: "cancelled", LastResetTime: now - 3600, NextResetTime: now + 120})
beforeReset := GetDBTimestamp()
result, err := AdminResetUserSubscriptionsByPlan(101, plan.Id, true)
afterReset := GetDBTimestamp()
require.NoError(t, err)
require.NotNil(t, result)
assert.Equal(t, plan.Id, result.PlanId)
assert.Equal(t, 2, result.MatchedCount)
assert.Equal(t, 2, result.ResetCount)
assert.Equal(t, 1, result.UserCount)
assert.Equal(t, []int{101}, result.AffectedUserIds)
assert.True(t, result.AdvanceResetTime)
for _, id := range []int{9201, 9202} {
sub := getSubscriptionResetSub(t, id)
assert.Zero(t, sub.AmountUsed)
assert.GreaterOrEqual(t, sub.LastResetTime, beforeReset)
assert.LessOrEqual(t, sub.LastResetTime, afterReset)
assert.Equal(t, calcNextResetTime(time.Unix(sub.LastResetTime, 0), plan, sub.EndTime), sub.NextResetTime)
}
assert.EqualValues(t, 60, getSubscriptionResetSub(t, 9203).AmountUsed)
assert.EqualValues(t, 700, getSubscriptionResetSub(t, 9204).AmountUsed)
assert.EqualValues(t, 800, getSubscriptionResetSub(t, 9205).AmountUsed)
assert.EqualValues(t, 900, getSubscriptionResetSub(t, 9206).AmountUsed)
}
func TestAdminResetUserSubscriptionsByPlanKeepsResetTimes(t *testing.T) {
truncateTables(t)
now := GetDBTimestamp()
plan := &SubscriptionPlan{
Id: 9301,
Title: "Team",
PriceAmount: 20,
DurationUnit: SubscriptionDurationMonth,
DurationValue: 1,
TotalAmount: 2000,
QuotaResetPeriod: SubscriptionResetMonthly,
}
seedSubscriptionResetPlan(t, plan)
lastReset := now - 86400
nextReset := now + 86400
seedSubscriptionResetSub(t, &UserSubscription{Id: 9302, UserId: 201, PlanId: plan.Id, AmountTotal: 2000, AmountUsed: 1200, StartTime: now - 172800, EndTime: now + 30*24*3600, Status: "active", LastResetTime: lastReset, NextResetTime: nextReset})
result, err := AdminResetUserSubscriptionsByPlan(201, plan.Id, false)
require.NoError(t, err)
assert.False(t, result.AdvanceResetTime)
sub := getSubscriptionResetSub(t, 9302)
assert.Zero(t, sub.AmountUsed)
assert.Equal(t, lastReset, sub.LastResetTime)
assert.Equal(t, nextReset, sub.NextResetTime)
}
func TestAdminResetUserSubscriptionsByPlanNoActiveMatchReturnsError(t *testing.T) {
truncateTables(t)
now := GetDBTimestamp()
plan := &SubscriptionPlan{
Id: 9401,
Title: "Expired",
PriceAmount: 10,
DurationUnit: SubscriptionDurationMonth,
DurationValue: 1,
TotalAmount: 1000,
}
seedSubscriptionResetPlan(t, plan)
seedSubscriptionResetSub(t, &UserSubscription{Id: 9402, UserId: 301, PlanId: plan.Id, AmountTotal: 1000, AmountUsed: 500, StartTime: now - 7200, EndTime: now - 1, Status: "active"})
result, err := AdminResetUserSubscriptionsByPlan(301, plan.Id, true)
require.Error(t, err)
assert.Nil(t, result)
assert.True(t, strings.Contains(err.Error(), "该用户没有有效的此套餐订阅"))
}
func TestAdminResetPlanSubscriptionsResetsAllActiveUsers(t *testing.T) {
truncateTables(t)
now := GetDBTimestamp()
plan := &SubscriptionPlan{
Id: 9501,
Title: "Business",
PriceAmount: 30,
DurationUnit: SubscriptionDurationMonth,
DurationValue: 1,
TotalAmount: 3000,
QuotaResetPeriod: SubscriptionResetNever,
}
seedSubscriptionResetPlan(t, plan)
activeEnd := now + 30*24*3600
seedSubscriptionResetSub(t, &UserSubscription{Id: 9502, UserId: 401, PlanId: plan.Id, AmountTotal: 3000, AmountUsed: 1000, StartTime: now - 3600, EndTime: activeEnd, Status: "active", LastResetTime: now - 3600, NextResetTime: now + 10})
seedSubscriptionResetSub(t, &UserSubscription{Id: 9503, UserId: 401, PlanId: plan.Id, AmountTotal: 3000, AmountUsed: 1100, StartTime: now - 3500, EndTime: activeEnd, Status: "active", LastResetTime: now - 3600, NextResetTime: now + 10})
seedSubscriptionResetSub(t, &UserSubscription{Id: 9504, UserId: 402, PlanId: plan.Id, AmountTotal: 3000, AmountUsed: 1200, StartTime: now - 3400, EndTime: activeEnd, Status: "active", LastResetTime: now - 3600, NextResetTime: now + 10})
seedSubscriptionResetSub(t, &UserSubscription{Id: 9505, UserId: 403, PlanId: plan.Id, AmountTotal: 3000, AmountUsed: 1300, StartTime: now - 7200, EndTime: now - 1, Status: "active", LastResetTime: now - 3600, NextResetTime: now - 10})
seedSubscriptionResetSub(t, &UserSubscription{Id: 9506, UserId: 404, PlanId: plan.Id, AmountTotal: 3000, AmountUsed: 1400, StartTime: now - 3600, EndTime: activeEnd, Status: "cancelled", LastResetTime: now - 3600, NextResetTime: now + 10})
result, err := AdminResetPlanSubscriptions(plan.Id, true)
require.NoError(t, err)
require.NotNil(t, result)
assert.Equal(t, 3, result.MatchedCount)
assert.Equal(t, 3, result.ResetCount)
assert.Equal(t, 2, result.UserCount)
assert.Equal(t, []int{401, 402}, result.AffectedUserIds)
for _, id := range []int{9502, 9503, 9504} {
sub := getSubscriptionResetSub(t, id)
assert.Zero(t, sub.AmountUsed)
assert.Zero(t, sub.LastResetTime)
assert.Zero(t, sub.NextResetTime)
}
assert.EqualValues(t, 1300, getSubscriptionResetSub(t, 9505).AmountUsed)
assert.EqualValues(t, 1400, getSubscriptionResetSub(t, 9506).AmountUsed)
}
func TestAdminResetPlanSubscriptionsNoMatchSucceeds(t *testing.T) {
truncateTables(t)
plan := &SubscriptionPlan{
Id: 9601,
Title: "Empty",
PriceAmount: 10,
DurationUnit: SubscriptionDurationMonth,
DurationValue: 1,
TotalAmount: 1000,
}
seedSubscriptionResetPlan(t, plan)
result, err := AdminResetPlanSubscriptions(plan.Id, true)
require.NoError(t, err)
require.NotNil(t, result)
assert.Zero(t, result.MatchedCount)
assert.Zero(t, result.ResetCount)
assert.Zero(t, result.UserCount)
assert.Empty(t, result.AffectedUserIds)
}
...@@ -168,10 +168,12 @@ func SetApiRouter(router *gin.Engine) { ...@@ -168,10 +168,12 @@ func SetApiRouter(router *gin.Engine) {
subscriptionAdminRoute.PUT("/plans/:id", controller.AdminUpdateSubscriptionPlan) subscriptionAdminRoute.PUT("/plans/:id", controller.AdminUpdateSubscriptionPlan)
subscriptionAdminRoute.PATCH("/plans/:id", controller.AdminUpdateSubscriptionPlanStatus) subscriptionAdminRoute.PATCH("/plans/:id", controller.AdminUpdateSubscriptionPlanStatus)
subscriptionAdminRoute.POST("/bind", controller.AdminBindSubscription) subscriptionAdminRoute.POST("/bind", controller.AdminBindSubscription)
subscriptionAdminRoute.POST("/plans/:id/subscriptions/reset", controller.AdminResetPlanSubscriptions)
// User subscription management (admin) // User subscription management (admin)
subscriptionAdminRoute.GET("/users/:id/subscriptions", controller.AdminListUserSubscriptions) subscriptionAdminRoute.GET("/users/:id/subscriptions", controller.AdminListUserSubscriptions)
subscriptionAdminRoute.POST("/users/:id/subscriptions", controller.AdminCreateUserSubscription) subscriptionAdminRoute.POST("/users/:id/subscriptions", controller.AdminCreateUserSubscription)
subscriptionAdminRoute.POST("/users/:id/subscriptions/reset", controller.AdminResetUserSubscriptionsByPlan)
subscriptionAdminRoute.POST("/user_subscriptions/:id/invalidate", controller.AdminInvalidateUserSubscription) subscriptionAdminRoute.POST("/user_subscriptions/:id/invalidate", controller.AdminInvalidateUserSubscription)
subscriptionAdminRoute.DELETE("/user_subscriptions/:id", controller.AdminDeleteUserSubscription) subscriptionAdminRoute.DELETE("/user_subscriptions/:id", controller.AdminDeleteUserSubscription)
} }
......
...@@ -24,6 +24,9 @@ import type { ...@@ -24,6 +24,9 @@ import type {
PlanPayload, PlanPayload,
UserSubscriptionRecord, UserSubscriptionRecord,
CreateUserSubscriptionRequest, CreateUserSubscriptionRequest,
ResetUserSubscriptionsRequest,
ResetPlanSubscriptionsRequest,
SubscriptionResetResult,
SubscriptionPayResponse, SubscriptionPayResponse,
SubscriptionPayRequest, SubscriptionPayRequest,
SelfSubscriptionData, SelfSubscriptionData,
...@@ -105,6 +108,28 @@ export async function deleteUserSubscription( ...@@ -105,6 +108,28 @@ export async function deleteUserSubscription(
return res.data return res.data
} }
export async function resetUserSubscriptionsByPlan(
userId: number,
data: ResetUserSubscriptionsRequest
): Promise<ApiResponse<SubscriptionResetResult>> {
const res = await api.post(
`/api/subscription/admin/users/${userId}/subscriptions/reset`,
data
)
return res.data
}
export async function resetPlanSubscriptions(
planId: number,
data: ResetPlanSubscriptionsRequest
): Promise<ApiResponse<SubscriptionResetResult>> {
const res = await api.post(
`/api/subscription/admin/plans/${planId}/subscriptions/reset`,
data
)
return res.data
}
// ============================================================================ // ============================================================================
// User-facing Subscription Payment // User-facing Subscription Payment
// ============================================================================ // ============================================================================
......
...@@ -17,7 +17,7 @@ along with this program. If not, see <https://www.gnu.org/licenses/>. ...@@ -17,7 +17,7 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com For commercial licensing, please contact support@quantumnous.com
*/ */
import type { Row } from '@tanstack/react-table' import type { Row } from '@tanstack/react-table'
import { Pencil, Power, PowerOff } from 'lucide-react' import { Pencil, Power, PowerOff, RotateCcw } from 'lucide-react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
...@@ -50,6 +50,11 @@ export function DataTableRowActions({ row }: DataTableRowActionsProps) { ...@@ -50,6 +50,11 @@ export function DataTableRowActions({ row }: DataTableRowActionsProps) {
setOpen('toggle-status') setOpen('toggle-status')
} }
const handleResetSubscriptions = () => {
setCurrentRow(row.original)
setOpen('reset-subscriptions')
}
return ( return (
<div className='-ml-1.5 flex items-center gap-1'> <div className='-ml-1.5 flex items-center gap-1'>
<Tooltip> <Tooltip>
...@@ -76,6 +81,23 @@ export function DataTableRowActions({ row }: DataTableRowActionsProps) { ...@@ -76,6 +81,23 @@ export function DataTableRowActions({ row }: DataTableRowActionsProps) {
variant='ghost' variant='ghost'
size='icon-sm' size='icon-sm'
disabled={!complianceConfirmed} disabled={!complianceConfirmed}
onClick={handleResetSubscriptions}
aria-label={t('Reset subscription quota')}
/>
}
>
<RotateCcw />
</TooltipTrigger>
<TooltipContent>{t('Reset subscription quota')}</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger
render={
<Button
variant='ghost'
size='icon-sm'
disabled={!complianceConfirmed}
onClick={handleToggleStatus} onClick={handleToggleStatus}
aria-label={toggleLabel} aria-label={toggleLabel}
className={ className={
......
/*
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 <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import { ConfirmDialog } from '@/components/confirm-dialog'
import { Switch } from '@/components/ui/switch'
import { resetPlanSubscriptions } from '../../api'
import { useSubscriptions } from '../subscriptions-provider'
export function ResetSubscriptionsDialog() {
const { t } = useTranslation()
const { open, setOpen, currentRow, triggerRefresh } = useSubscriptions()
const [advanceResetTime, setAdvanceResetTime] = useState(true)
const [resetting, setResetting] = useState(false)
const isOpen = open === 'reset-subscriptions'
const plan = currentRow?.plan
const planLabel = plan?.title || (plan?.id ? `#${plan.id}` : '-')
useEffect(() => {
if (isOpen) setAdvanceResetTime(true)
}, [isOpen])
const handleConfirm = async () => {
if (!plan?.id) return
setResetting(true)
try {
const res = await resetPlanSubscriptions(plan.id, {
advance_reset_time: advanceResetTime,
})
if (res.success) {
toast.success(
t('Reset {{count}} active subscriptions', {
count: res.data?.reset_count || 0,
})
)
triggerRefresh()
setOpen(null)
}
} catch {
toast.error(t('Operation failed'))
} finally {
setResetting(false)
}
}
return (
<ConfirmDialog
open={isOpen}
onOpenChange={(nextOpen) => !nextOpen && setOpen(null)}
title={t('Reset subscription quota')}
desc={t('Reset all active subscriptions under {{plan}}?', {
plan: planLabel,
})}
confirmText={t('Reset quota')}
handleConfirm={handleConfirm}
disabled={!plan?.id}
isLoading={resetting}
>
<label className='flex items-center justify-between gap-3 rounded-md border px-3 py-2 text-sm'>
<span>{t('Advance next reset time')}</span>
<Switch
checked={advanceResetTime}
onCheckedChange={(checked) => setAdvanceResetTime(!!checked)}
aria-label={t('Advance next reset time')}
/>
</label>
</ConfirmDialog>
)
}
...@@ -16,13 +16,13 @@ along with this program. If not, see <https://www.gnu.org/licenses/>. ...@@ -16,13 +16,13 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com For commercial licensing, please contact support@quantumnous.com
*/ */
import { Plus } from 'lucide-react' import { Ban, Plus, RotateCcw, Trash2 } from 'lucide-react'
import { useCallback, useEffect, useMemo, useState } from 'react' import { useCallback, useEffect, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { toast } from 'sonner' import { toast } from 'sonner'
import { ConfirmDialog } from '@/components/confirm-dialog' import { ConfirmDialog } from '@/components/confirm-dialog'
import { StaticDataTable } from '@/components/data-table' import { DataTableRowActionMenu, StaticDataTable } from '@/components/data-table'
import { import {
sideDrawerContentClassName, sideDrawerContentClassName,
sideDrawerFormClassName, sideDrawerFormClassName,
...@@ -32,6 +32,11 @@ import { StatusBadge } from '@/components/status-badge' ...@@ -32,6 +32,11 @@ import { StatusBadge } from '@/components/status-badge'
import { TableId } from '@/components/table-id' import { TableId } from '@/components/table-id'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { import {
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuShortcut,
} from '@/components/ui/dropdown-menu'
import {
Select, Select,
SelectContent, SelectContent,
SelectGroup, SelectGroup,
...@@ -46,6 +51,7 @@ import { ...@@ -46,6 +51,7 @@ import {
SheetTitle, SheetTitle,
SheetDescription, SheetDescription,
} from '@/components/ui/sheet' } from '@/components/ui/sheet'
import { Switch } from '@/components/ui/switch'
import { formatQuota } from '@/lib/format' import { formatQuota } from '@/lib/format'
import { import {
...@@ -54,6 +60,7 @@ import { ...@@ -54,6 +60,7 @@ import {
createUserSubscription, createUserSubscription,
invalidateUserSubscription, invalidateUserSubscription,
deleteUserSubscription, deleteUserSubscription,
resetUserSubscriptionsByPlan,
} from '../../api' } from '../../api'
import { formatTimestamp } from '../../lib' import { formatTimestamp } from '../../lib'
import type { PlanRecord, UserSubscriptionRecord } from '../../types' import type { PlanRecord, UserSubscriptionRecord } from '../../types'
...@@ -73,7 +80,7 @@ function SubscriptionStatusBadge(props: { ...@@ -73,7 +80,7 @@ function SubscriptionStatusBadge(props: {
const now = Date.now() / 1000 const now = Date.now() / 1000
const isExpired = (props.sub.end_time || 0) > 0 && props.sub.end_time < now const isExpired = (props.sub.end_time || 0) > 0 && props.sub.end_time < now
const isActive = props.sub.status === 'active' && !isExpired const isActive = props.sub.status === 'active' && !isExpired
if (isActive) if (isActive) {
return ( return (
<StatusBadge <StatusBadge
label={props.t('Active')} label={props.t('Active')}
...@@ -81,7 +88,8 @@ function SubscriptionStatusBadge(props: { ...@@ -81,7 +88,8 @@ function SubscriptionStatusBadge(props: {
copyable={false} copyable={false}
/> />
) )
if (props.sub.status === 'cancelled') }
if (props.sub.status === 'cancelled') {
return ( return (
<StatusBadge <StatusBadge
label={props.t('Invalidated')} label={props.t('Invalidated')}
...@@ -89,6 +97,7 @@ function SubscriptionStatusBadge(props: { ...@@ -89,6 +97,7 @@ function SubscriptionStatusBadge(props: {
copyable={false} copyable={false}
/> />
) )
}
return ( return (
<StatusBadge <StatusBadge
label={props.t('Expired')} label={props.t('Expired')}
...@@ -105,6 +114,12 @@ export function UserSubscriptionsDialog(props: Props) { ...@@ -105,6 +114,12 @@ export function UserSubscriptionsDialog(props: Props) {
const [plans, setPlans] = useState<PlanRecord[]>([]) const [plans, setPlans] = useState<PlanRecord[]>([])
const [subs, setSubs] = useState<UserSubscriptionRecord[]>([]) const [subs, setSubs] = useState<UserSubscriptionRecord[]>([])
const [selectedPlanId, setSelectedPlanId] = useState<string>('') const [selectedPlanId, setSelectedPlanId] = useState<string>('')
const [resetting, setResetting] = useState(false)
const [advanceResetTime, setAdvanceResetTime] = useState(true)
const [resetAction, setResetAction] = useState<{
planId: number
planTitle: string
} | null>(null)
const [confirmAction, setConfirmAction] = useState<{ const [confirmAction, setConfirmAction] = useState<{
type: 'invalidate' | 'delete' type: 'invalidate' | 'delete'
subId: number subId: number
...@@ -190,6 +205,31 @@ export function UserSubscriptionsDialog(props: Props) { ...@@ -190,6 +205,31 @@ export function UserSubscriptionsDialog(props: Props) {
} }
} }
const handleResetConfirm = async () => {
if (!props.user?.id || !resetAction) return
setResetting(true)
try {
const res = await resetUserSubscriptionsByPlan(props.user.id, {
plan_id: resetAction.planId,
advance_reset_time: advanceResetTime,
})
if (res.success) {
toast.success(
t('Reset {{count}} active subscriptions', {
count: res.data?.reset_count || 0,
})
)
await loadData()
props.onSuccess?.()
}
} catch {
toast.error(t('Operation failed'))
} finally {
setResetting(false)
setResetAction(null)
}
}
return ( return (
<> <>
<Sheet open={props.open} onOpenChange={props.onOpenChange}> <Sheet open={props.open} onOpenChange={props.onOpenChange}>
...@@ -204,17 +244,15 @@ export function UserSubscriptionsDialog(props: Props) { ...@@ -204,17 +244,15 @@ export function UserSubscriptionsDialog(props: Props) {
<div className={sideDrawerFormClassName()}> <div className={sideDrawerFormClassName()}>
<div className='flex gap-2'> <div className='flex gap-2'>
<Select <Select
items={[ items={plans.map((p) => ({
...plans.map((p) => ({ value: String(p.plan.id),
value: String(p.plan.id), label: (
label: ( <>
<> {p.plan.title}($
{p.plan.title}($ {Number(p.plan.price_amount || 0).toFixed(2)})
{Number(p.plan.price_amount || 0).toFixed(2)}) </>
</> ),
), }))}
})),
]}
value={selectedPlanId} value={selectedPlanId}
onValueChange={(v) => v !== null && setSelectedPlanId(v)} onValueChange={(v) => v !== null && setSelectedPlanId(v)}
> >
...@@ -322,10 +360,25 @@ export function UserSubscriptionsDialog(props: Props) { ...@@ -322,10 +360,25 @@ export function UserSubscriptionsDialog(props: Props) {
const isActive = sub.status === 'active' && !isExpired const isActive = sub.status === 'active' && !isExpired
return ( return (
<div className='flex justify-end gap-1'> <DataTableRowActionMenu ariaLabel={t('Actions')}>
<Button <DropdownMenuItem
size='sm' disabled={!isActive}
variant='outline' onClick={() => {
setAdvanceResetTime(true)
setResetAction({
planId: sub.plan_id,
planTitle:
planTitleMap.get(sub.plan_id) ||
`#${sub.plan_id}`,
})
}}
>
{t('Reset quota')}
<DropdownMenuShortcut>
<RotateCcw size={16} />
</DropdownMenuShortcut>
</DropdownMenuItem>
<DropdownMenuItem
disabled={!isActive} disabled={!isActive}
onClick={() => onClick={() =>
setConfirmAction({ setConfirmAction({
...@@ -335,9 +388,12 @@ export function UserSubscriptionsDialog(props: Props) { ...@@ -335,9 +388,12 @@ export function UserSubscriptionsDialog(props: Props) {
} }
> >
{t('Invalidate')} {t('Invalidate')}
</Button> <DropdownMenuShortcut>
<Button <Ban size={16} />
size='sm' </DropdownMenuShortcut>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
variant='destructive' variant='destructive'
onClick={() => onClick={() =>
setConfirmAction({ setConfirmAction({
...@@ -347,8 +403,11 @@ export function UserSubscriptionsDialog(props: Props) { ...@@ -347,8 +403,11 @@ export function UserSubscriptionsDialog(props: Props) {
} }
> >
{t('Delete')} {t('Delete')}
</Button> <DropdownMenuShortcut>
</div> <Trash2 size={16} />
</DropdownMenuShortcut>
</DropdownMenuItem>
</DataTableRowActionMenu>
) )
}, },
}, },
...@@ -380,6 +439,29 @@ export function UserSubscriptionsDialog(props: Props) { ...@@ -380,6 +439,29 @@ export function UserSubscriptionsDialog(props: Props) {
destructive={confirmAction.type === 'delete'} destructive={confirmAction.type === 'delete'}
/> />
)} )}
{resetAction && (
<ConfirmDialog
open
onOpenChange={(v) => !v && setResetAction(null)}
title={t('Reset subscription quota')}
desc={t('Reset active {{plan}} subscriptions for this user?', {
plan: resetAction.planTitle,
})}
confirmText={t('Reset quota')}
handleConfirm={handleResetConfirm}
isLoading={resetting}
>
<label className='flex items-center justify-between gap-3 rounded-md border px-3 py-2 text-sm'>
<span>{t('Advance next reset time')}</span>
<Switch
checked={advanceResetTime}
onCheckedChange={(checked) => setAdvanceResetTime(!!checked)}
aria-label={t('Advance next reset time')}
/>
</label>
</ConfirmDialog>
)}
</> </>
) )
} }
...@@ -16,6 +16,7 @@ along with this program. If not, see <https://www.gnu.org/licenses/>. ...@@ -16,6 +16,7 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com For commercial licensing, please contact support@quantumnous.com
*/ */
import { ResetSubscriptionsDialog } from './dialogs/reset-subscriptions-dialog'
import { ToggleStatusDialog } from './dialogs/toggle-status-dialog' import { ToggleStatusDialog } from './dialogs/toggle-status-dialog'
import { SubscriptionsMutateDrawer } from './subscriptions-mutate-drawer' import { SubscriptionsMutateDrawer } from './subscriptions-mutate-drawer'
import { useSubscriptions } from './subscriptions-provider' import { useSubscriptions } from './subscriptions-provider'
...@@ -32,6 +33,7 @@ export function SubscriptionsDialogs() { ...@@ -32,6 +33,7 @@ export function SubscriptionsDialogs() {
currentRow={isUpdate ? currentRow || undefined : undefined} currentRow={isUpdate ? currentRow || undefined : undefined}
/> />
<ToggleStatusDialog /> <ToggleStatusDialog />
<ResetSubscriptionsDialog />
</> </>
) )
} }
...@@ -117,6 +117,23 @@ export interface CreateUserSubscriptionRequest { ...@@ -117,6 +117,23 @@ export interface CreateUserSubscriptionRequest {
plan_id: number plan_id: number
} }
export interface ResetUserSubscriptionsRequest {
plan_id: number
advance_reset_time: boolean
}
export interface ResetPlanSubscriptionsRequest {
advance_reset_time: boolean
}
export interface SubscriptionResetResult {
plan_id: number
matched_count: number
reset_count: number
user_count: number
advance_reset_time: boolean
}
// ============================================================================ // ============================================================================
// Self Subscription Data (user-facing) // Self Subscription Data (user-facing)
// ============================================================================ // ============================================================================
...@@ -131,4 +148,8 @@ export interface SelfSubscriptionData { ...@@ -131,4 +148,8 @@ export interface SelfSubscriptionData {
// Dialog Types // Dialog Types
// ============================================================================ // ============================================================================
export type SubscriptionsDialogType = 'create' | 'update' | 'toggle-status' export type SubscriptionsDialogType =
| 'create'
| 'update'
| 'toggle-status'
| 'reset-subscriptions'
...@@ -238,6 +238,7 @@ ...@@ -238,6 +238,7 @@
"Administer user accounts and roles.": "Administer user accounts and roles.", "Administer user accounts and roles.": "Administer user accounts and roles.",
"Administrator account": "Administrator account", "Administrator account": "Administrator account",
"Administrator username": "Administrator username", "Administrator username": "Administrator username",
"Advance next reset time": "Advance next reset time",
"Advanced": "Advanced", "Advanced": "Advanced",
"Advanced Configuration": "Advanced Configuration", "Advanced Configuration": "Advanced Configuration",
"Advanced Custom": "Advanced Custom", "Advanced Custom": "Advanced Custom",
...@@ -3715,8 +3716,11 @@ ...@@ -3715,8 +3716,11 @@
"Resend ({{seconds}}s)": "Resend ({{seconds}}s)", "Resend ({{seconds}}s)": "Resend ({{seconds}}s)",
"Reserved for viewing complete channel keys after secure verification.": "Reserved for viewing complete channel keys after secure verification.", "Reserved for viewing complete channel keys after secure verification.": "Reserved for viewing complete channel keys after secure verification.",
"Reset": "Reset", "Reset": "Reset",
"Reset {{count}} active subscriptions": "Reset {{count}} active subscriptions",
"Reset 2FA": "Reset 2FA", "Reset 2FA": "Reset 2FA",
"Reset 2FA for {{username}}? The user must set up 2FA again to continue using it.": "Reset 2FA for {{username}}? The user must set up 2FA again to continue using it.", "Reset 2FA for {{username}}? The user must set up 2FA again to continue using it.": "Reset 2FA for {{username}}? The user must set up 2FA again to continue using it.",
"Reset active {{plan}} subscriptions for this user?": "Reset active {{plan}} subscriptions for this user?",
"Reset all active subscriptions under {{plan}}?": "Reset all active subscriptions under {{plan}}?",
"Reset all model prices?": "Reset all model prices?", "Reset all model prices?": "Reset all model prices?",
"Reset all model ratios?": "Reset all model ratios?", "Reset all model ratios?": "Reset all model ratios?",
"Reset all settings to default values": "Reset all settings to default values", "Reset all settings to default values": "Reset all settings to default values",
...@@ -3736,8 +3740,10 @@ ...@@ -3736,8 +3740,10 @@
"Reset password": "Reset password", "Reset password": "Reset password",
"Reset Period": "Reset Period", "Reset Period": "Reset Period",
"Reset prices": "Reset prices", "Reset prices": "Reset prices",
"Reset quota": "Reset quota",
"Reset ratios": "Reset ratios", "Reset ratios": "Reset ratios",
"Reset Stats": "Reset Stats", "Reset Stats": "Reset Stats",
"Reset subscription quota": "Reset subscription quota",
"Reset the user passkey": "Reset the user passkey", "Reset the user passkey": "Reset the user passkey",
"Reset to default": "Reset to default", "Reset to default": "Reset to default",
"Reset to Default": "Reset to Default", "Reset to Default": "Reset to Default",
...@@ -3929,6 +3935,7 @@ ...@@ -3929,6 +3935,7 @@
"Select a timestamp before clearing logs.": "Select a timestamp before clearing logs.", "Select a timestamp before clearing logs.": "Select a timestamp before clearing logs.",
"Select a usage mode to continue": "Select a usage mode to continue", "Select a usage mode to continue": "Select a usage mode to continue",
"Select a verification method first": "Select a verification method first", "Select a verification method first": "Select a verification method first",
"Select active subscription plan": "Select active subscription plan",
"Select all": "Select all", "Select all": "Select all",
"Select all (filtered)": "Select all (filtered)", "Select all (filtered)": "Select all (filtered)",
"Select all models": "Select all models", "Select all models": "Select all models",
......
...@@ -238,6 +238,7 @@ ...@@ -238,6 +238,7 @@
"Administer user accounts and roles.": "Gérer les comptes d'utilisateurs et les rôles.", "Administer user accounts and roles.": "Gérer les comptes d'utilisateurs et les rôles.",
"Administrator account": "Compte administrateur", "Administrator account": "Compte administrateur",
"Administrator username": "Nom d'utilisateur administrateur", "Administrator username": "Nom d'utilisateur administrateur",
"Advance next reset time": "Avancer la prochaine réinitialisation",
"Advanced": "Avancé", "Advanced": "Avancé",
"Advanced Configuration": "Configuration avancée", "Advanced Configuration": "Configuration avancée",
"Advanced Custom": "Personnalisé avancé", "Advanced Custom": "Personnalisé avancé",
...@@ -3715,8 +3716,11 @@ ...@@ -3715,8 +3716,11 @@
"Resend ({{seconds}}s)": "Renvoyer ({{seconds}}s)", "Resend ({{seconds}}s)": "Renvoyer ({{seconds}}s)",
"Reserved for viewing complete channel keys after secure verification.": "Réservé à l'affichage des clés complètes des canaux après une vérification sécurisée.", "Reserved for viewing complete channel keys after secure verification.": "Réservé à l'affichage des clés complètes des canaux après une vérification sécurisée.",
"Reset": "Réinitialiser", "Reset": "Réinitialiser",
"Reset {{count}} active subscriptions": "{{count}} abonnements actifs réinitialisés",
"Reset 2FA": "Réinitialiser la 2FA", "Reset 2FA": "Réinitialiser la 2FA",
"Reset 2FA for {{username}}? The user must set up 2FA again to continue using it.": "Réinitialiser la 2FA de {{username}} ? L’utilisateur devra configurer à nouveau la 2FA pour continuer à l’utiliser.", "Reset 2FA for {{username}}? The user must set up 2FA again to continue using it.": "Réinitialiser la 2FA de {{username}} ? L’utilisateur devra configurer à nouveau la 2FA pour continuer à l’utiliser.",
"Reset active {{plan}} subscriptions for this user?": "Réinitialiser les abonnements {{plan}} actifs de cet utilisateur ?",
"Reset all active subscriptions under {{plan}}?": "Réinitialiser tous les abonnements actifs de {{plan}} ?",
"Reset all model prices?": "Réinitialiser tous les prix des modèles ?", "Reset all model prices?": "Réinitialiser tous les prix des modèles ?",
"Reset all model ratios?": "Réinitialiser tous les ratios de modèle ?", "Reset all model ratios?": "Réinitialiser tous les ratios de modèle ?",
"Reset all settings to default values": "Réinitialiser tous les paramètres aux valeurs par défaut", "Reset all settings to default values": "Réinitialiser tous les paramètres aux valeurs par défaut",
...@@ -3736,8 +3740,10 @@ ...@@ -3736,8 +3740,10 @@
"Reset password": "Réinitialiser le mot de passe", "Reset password": "Réinitialiser le mot de passe",
"Reset Period": "Période de réinitialisation", "Reset Period": "Période de réinitialisation",
"Reset prices": "Réinitialiser les prix", "Reset prices": "Réinitialiser les prix",
"Reset quota": "Réinitialiser le quota",
"Reset ratios": "Réinitialiser les ratios", "Reset ratios": "Réinitialiser les ratios",
"Reset Stats": "Réinitialiser les statistiques", "Reset Stats": "Réinitialiser les statistiques",
"Reset subscription quota": "Réinitialiser le quota d'abonnement",
"Reset the user passkey": "Clé d'accès de l'utilisateur réinitialisée", "Reset the user passkey": "Clé d'accès de l'utilisateur réinitialisée",
"Reset to default": "Réinitialiser par défaut", "Reset to default": "Réinitialiser par défaut",
"Reset to Default": "Réinitialiser par défaut", "Reset to Default": "Réinitialiser par défaut",
...@@ -3929,6 +3935,7 @@ ...@@ -3929,6 +3935,7 @@
"Select a timestamp before clearing logs.": "Sélectionnez un horodatage avant de vider les journaux.", "Select a timestamp before clearing logs.": "Sélectionnez un horodatage avant de vider les journaux.",
"Select a usage mode to continue": "Sélectionnez un mode d'utilisation pour continuer", "Select a usage mode to continue": "Sélectionnez un mode d'utilisation pour continuer",
"Select a verification method first": "Sélectionnez d'abord une méthode de vérification", "Select a verification method first": "Sélectionnez d'abord une méthode de vérification",
"Select active subscription plan": "Sélectionner un forfait actif",
"Select all": "Tout sélectionner", "Select all": "Tout sélectionner",
"Select all (filtered)": "Tout sélectionner (filtré)", "Select all (filtered)": "Tout sélectionner (filtré)",
"Select all models": "Sélectionner tous les modèles", "Select all models": "Sélectionner tous les modèles",
......
...@@ -238,6 +238,7 @@ ...@@ -238,6 +238,7 @@
"Administer user accounts and roles.": "ユーザーアカウントとロールを管理します。", "Administer user accounts and roles.": "ユーザーアカウントとロールを管理します。",
"Administrator account": "管理者アカウント", "Administrator account": "管理者アカウント",
"Administrator username": "管理者ユーザー名", "Administrator username": "管理者ユーザー名",
"Advance next reset time": "次回リセット時刻を進める",
"Advanced": "高度な設定", "Advanced": "高度な設定",
"Advanced Configuration": "詳細設定", "Advanced Configuration": "詳細設定",
"Advanced Custom": "高度なカスタム", "Advanced Custom": "高度なカスタム",
...@@ -3715,8 +3716,11 @@ ...@@ -3715,8 +3716,11 @@
"Resend ({{seconds}}s)": "再送信 ({{seconds}}秒)", "Resend ({{seconds}}s)": "再送信 ({{seconds}}秒)",
"Reserved for viewing complete channel keys after secure verification.": "安全な検証後に完全なチャンネルキーを表示するために予約されています。", "Reserved for viewing complete channel keys after secure verification.": "安全な検証後に完全なチャンネルキーを表示するために予約されています。",
"Reset": "リセット", "Reset": "リセット",
"Reset {{count}} active subscriptions": "{{count}} 件の有効なサブスクリプションをリセットしました",
"Reset 2FA": "2FAをリセット", "Reset 2FA": "2FAをリセット",
"Reset 2FA for {{username}}? The user must set up 2FA again to continue using it.": "{{username}} の 2FA をリセットしますか?引き続き使用するには、2FA を再設定する必要があります。", "Reset 2FA for {{username}}? The user must set up 2FA again to continue using it.": "{{username}} の 2FA をリセットしますか?引き続き使用するには、2FA を再設定する必要があります。",
"Reset active {{plan}} subscriptions for this user?": "このユーザーの有効な {{plan}} サブスクリプションをリセットしますか?",
"Reset all active subscriptions under {{plan}}?": "{{plan}} のすべての有効なサブスクリプションをリセットしますか?",
"Reset all model prices?": "すべてのモデル価格をリセットしますか?", "Reset all model prices?": "すべてのモデル価格をリセットしますか?",
"Reset all model ratios?": "すべてのモデル比率をリセットしますか?", "Reset all model ratios?": "すべてのモデル比率をリセットしますか?",
"Reset all settings to default values": "すべての設定をデフォルト値にリセット", "Reset all settings to default values": "すべての設定をデフォルト値にリセット",
...@@ -3736,8 +3740,10 @@ ...@@ -3736,8 +3740,10 @@
"Reset password": "パスワードをリセット", "Reset password": "パスワードをリセット",
"Reset Period": "リセット期間", "Reset Period": "リセット期間",
"Reset prices": "価格をリセット", "Reset prices": "価格をリセット",
"Reset quota": "クォータをリセット",
"Reset ratios": "比率をリセット", "Reset ratios": "比率をリセット",
"Reset Stats": "統計をリセット", "Reset Stats": "統計をリセット",
"Reset subscription quota": "サブスクリプションのクォータをリセット",
"Reset the user passkey": "ユーザーのパスキーをリセットしました", "Reset the user passkey": "ユーザーのパスキーをリセットしました",
"Reset to default": "デフォルトにリセット", "Reset to default": "デフォルトにリセット",
"Reset to Default": "デフォルトにリセット", "Reset to Default": "デフォルトにリセット",
...@@ -3929,6 +3935,7 @@ ...@@ -3929,6 +3935,7 @@
"Select a timestamp before clearing logs.": "ログをクリアする前にタイムスタンプを選択してください。", "Select a timestamp before clearing logs.": "ログをクリアする前にタイムスタンプを選択してください。",
"Select a usage mode to continue": "続行するには使用モードを選択してください", "Select a usage mode to continue": "続行するには使用モードを選択してください",
"Select a verification method first": "まず検証方法を選択してください", "Select a verification method first": "まず検証方法を選択してください",
"Select active subscription plan": "有効なサブスクリプションプランを選択",
"Select all": "すべて選択", "Select all": "すべて選択",
"Select all (filtered)": "フィルタ結果をすべて選択(S)", "Select all (filtered)": "フィルタ結果をすべて選択(S)",
"Select all models": "すべてのモデルを選択", "Select all models": "すべてのモデルを選択",
......
...@@ -238,6 +238,7 @@ ...@@ -238,6 +238,7 @@
"Administer user accounts and roles.": "Управление учетными записями пользователей и ролями.", "Administer user accounts and roles.": "Управление учетными записями пользователей и ролями.",
"Administrator account": "Учетная запись администратора", "Administrator account": "Учетная запись администратора",
"Administrator username": "Имя пользователя администратора", "Administrator username": "Имя пользователя администратора",
"Advance next reset time": "Перенести следующее время сброса",
"Advanced": "Расширенные", "Advanced": "Расширенные",
"Advanced Configuration": "Расширенная конфигурация", "Advanced Configuration": "Расширенная конфигурация",
"Advanced Custom": "Расширенный пользовательский", "Advanced Custom": "Расширенный пользовательский",
...@@ -3715,8 +3716,11 @@ ...@@ -3715,8 +3716,11 @@
"Resend ({{seconds}}s)": "Отправить повторно ({{seconds}}с)", "Resend ({{seconds}}s)": "Отправить повторно ({{seconds}}с)",
"Reserved for viewing complete channel keys after secure verification.": "Зарезервировано для просмотра полных ключей каналов после безопасной проверки.", "Reserved for viewing complete channel keys after secure verification.": "Зарезервировано для просмотра полных ключей каналов после безопасной проверки.",
"Reset": "Сброс", "Reset": "Сброс",
"Reset {{count}} active subscriptions": "Сброшено активных подписок: {{count}}",
"Reset 2FA": "Сбросить 2FA", "Reset 2FA": "Сбросить 2FA",
"Reset 2FA for {{username}}? The user must set up 2FA again to continue using it.": "Сбросить 2FA для {{username}}? Пользователь должен будет настроить 2FA заново, чтобы продолжить ее использовать.", "Reset 2FA for {{username}}? The user must set up 2FA again to continue using it.": "Сбросить 2FA для {{username}}? Пользователь должен будет настроить 2FA заново, чтобы продолжить ее использовать.",
"Reset active {{plan}} subscriptions for this user?": "Сбросить активные подписки {{plan}} для этого пользователя?",
"Reset all active subscriptions under {{plan}}?": "Сбросить все активные подписки по {{plan}}?",
"Reset all model prices?": "Сбросить все цены моделей?", "Reset all model prices?": "Сбросить все цены моделей?",
"Reset all model ratios?": "Сбросить все соотношения моделей?", "Reset all model ratios?": "Сбросить все соотношения моделей?",
"Reset all settings to default values": "Сбросить все настройки до значений по умолчанию", "Reset all settings to default values": "Сбросить все настройки до значений по умолчанию",
...@@ -3736,8 +3740,10 @@ ...@@ -3736,8 +3740,10 @@
"Reset password": "Сбросить пароль", "Reset password": "Сбросить пароль",
"Reset Period": "Период сброса", "Reset Period": "Период сброса",
"Reset prices": "Сбросить цены", "Reset prices": "Сбросить цены",
"Reset quota": "Сбросить квоту",
"Reset ratios": "Сбросить соотношения", "Reset ratios": "Сбросить соотношения",
"Reset Stats": "Сбросить статистику", "Reset Stats": "Сбросить статистику",
"Reset subscription quota": "Сбросить квоту подписки",
"Reset the user passkey": "Ключ доступа пользователя сброшен", "Reset the user passkey": "Ключ доступа пользователя сброшен",
"Reset to default": "Сбросить до значений по умолчанию", "Reset to default": "Сбросить до значений по умолчанию",
"Reset to Default": "Сбросить по умолчанию", "Reset to Default": "Сбросить по умолчанию",
...@@ -3929,6 +3935,7 @@ ...@@ -3929,6 +3935,7 @@
"Select a timestamp before clearing logs.": "Выберите временную метку перед очисткой журналов.", "Select a timestamp before clearing logs.": "Выберите временную метку перед очисткой журналов.",
"Select a usage mode to continue": "Выберите режим использования для продолжения", "Select a usage mode to continue": "Выберите режим использования для продолжения",
"Select a verification method first": "Сначала выберите метод верификации", "Select a verification method first": "Сначала выберите метод верификации",
"Select active subscription plan": "Выберите активный тариф подписки",
"Select all": "Выбрать все", "Select all": "Выбрать все",
"Select all (filtered)": "& Выбрать все отфильтрованные", "Select all (filtered)": "& Выбрать все отфильтрованные",
"Select all models": "Выбрать все модели", "Select all models": "Выбрать все модели",
......
...@@ -238,6 +238,7 @@ ...@@ -238,6 +238,7 @@
"Administer user accounts and roles.": "Quản lý tài khoản người dùng và vai trò.", "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 account": "Tài khoản quản trị viên",
"Administrator username": "Tên người dùng quản trị viên", "Administrator username": "Tên người dùng quản trị viên",
"Advance next reset time": "Dời thời gian đặt lại tiếp theo",
"Advanced": "Nâng cao", "Advanced": "Nâng cao",
"Advanced Configuration": "Cấu hình nâng cao", "Advanced Configuration": "Cấu hình nâng cao",
"Advanced Custom": "Tùy chỉnh nâng cao", "Advanced Custom": "Tùy chỉnh nâng cao",
...@@ -3715,8 +3716,11 @@ ...@@ -3715,8 +3716,11 @@
"Resend ({{seconds}}s)": "Gửi lại ({{seconds}}s)", "Resend ({{seconds}}s)": "Gửi lại ({{seconds}}s)",
"Reserved for viewing complete channel keys after secure verification.": "Dành riêng để xem khóa kênh đầy đủ sau khi xác minh bảo mật.", "Reserved for viewing complete channel keys after secure verification.": "Dành riêng để xem khóa kênh đầy đủ sau khi xác minh bảo mật.",
"Reset": "Đặt lại", "Reset": "Đặt lại",
"Reset {{count}} active subscriptions": "Đã đặt lại {{count}} gói đăng ký đang hoạt động",
"Reset 2FA": "Đặt lại 2FA", "Reset 2FA": "Đặt lại 2FA",
"Reset 2FA for {{username}}? The user must set up 2FA again to continue using it.": "Đặt lại 2FA cho {{username}}? Người dùng phải thiết lập lại 2FA để tiếp tục sử dụng.", "Reset 2FA for {{username}}? The user must set up 2FA again to continue using it.": "Đặt lại 2FA cho {{username}}? Người dùng phải thiết lập lại 2FA để tiếp tục sử dụng.",
"Reset active {{plan}} subscriptions for this user?": "Đặt lại các gói đăng ký {{plan}} đang hoạt động của người dùng này?",
"Reset all active subscriptions under {{plan}}?": "Đặt lại tất cả gói đăng ký đang hoạt động trong {{plan}}?",
"Reset all model prices?": "Đặt lại tất cả giá mô hình?", "Reset all model prices?": "Đặt lại tất cả giá mô hình?",
"Reset all model ratios?": "Đặt lại tất cả tỷ lệ mô hình?", "Reset all model ratios?": "Đặt lại tất cả tỷ lệ mô hình?",
"Reset all settings to default values": "Đặt lại tất cả cài đặt về giá trị mặc định", "Reset all settings to default values": "Đặt lại tất cả cài đặt về giá trị mặc định",
...@@ -3736,8 +3740,10 @@ ...@@ -3736,8 +3740,10 @@
"Reset password": "Đặt lại mật khẩu", "Reset password": "Đặt lại mật khẩu",
"Reset Period": "Chu kỳ đặt lại", "Reset Period": "Chu kỳ đặt lại",
"Reset prices": "Đặt lại giá", "Reset prices": "Đặt lại giá",
"Reset quota": "Đặt lại hạn mức",
"Reset ratios": "Đặt lại tỷ lệ", "Reset ratios": "Đặt lại tỷ lệ",
"Reset Stats": "Đặt lại thống kê", "Reset Stats": "Đặt lại thống kê",
"Reset subscription quota": "Đặt lại hạn mức gói đăng ký",
"Reset the user passkey": "Đã đặt lại passkey của người dùng", "Reset the user passkey": "Đã đặt lại passkey của người dùng",
"Reset to default": "Đặt lại mặc định", "Reset to default": "Đặt lại mặc định",
"Reset to Default": "Đặt lại mặc định", "Reset to Default": "Đặt lại mặc định",
...@@ -3929,6 +3935,7 @@ ...@@ -3929,6 +3935,7 @@
"Select a timestamp before clearing logs.": "Chọn một dấu thời gian trước khi xóa nhật ký.", "Select a timestamp before clearing logs.": "Chọn một dấu thời gian trước khi xóa nhật ký.",
"Select a usage mode to continue": "Chọn chế độ sử dụng để tiếp tục", "Select a usage mode to continue": "Chọn chế độ sử dụng để tiếp tục",
"Select a verification method first": "Vui lòng chọn phương thức xác thực trước", "Select a verification method first": "Vui lòng chọn phương thức xác thực trước",
"Select active subscription plan": "Chọn gói đăng ký đang hoạt động",
"Select all": "Chọn tất cả", "Select all": "Chọn tất cả",
"Select all (filtered)": "Chọn tất cả (đã lọc)", "Select all (filtered)": "Chọn tất cả (đã lọc)",
"Select all models": "Chọn tất cả mô hình", "Select all models": "Chọn tất cả mô hình",
......
...@@ -238,6 +238,7 @@ ...@@ -238,6 +238,7 @@
"Administer user accounts and roles.": "管理用户账户和角色。", "Administer user accounts and roles.": "管理用户账户和角色。",
"Administrator account": "管理员账户", "Administrator account": "管理员账户",
"Administrator username": "管理员用户名", "Administrator username": "管理员用户名",
"Advance next reset time": "推进下次重置时间",
"Advanced": "高级", "Advanced": "高级",
"Advanced Configuration": "高级配置", "Advanced Configuration": "高级配置",
"Advanced Custom": "高级自定义", "Advanced Custom": "高级自定义",
...@@ -3715,8 +3716,11 @@ ...@@ -3715,8 +3716,11 @@
"Resend ({{seconds}}s)": "重新发送 ({{seconds}}s)", "Resend ({{seconds}}s)": "重新发送 ({{seconds}}s)",
"Reserved for viewing complete channel keys after secure verification.": "预留用于在安全验证后查看完整渠道密钥。", "Reserved for viewing complete channel keys after secure verification.": "预留用于在安全验证后查看完整渠道密钥。",
"Reset": "重置", "Reset": "重置",
"Reset {{count}} active subscriptions": "已重置 {{count}} 个有效订阅",
"Reset 2FA": "重置 2FA", "Reset 2FA": "重置 2FA",
"Reset 2FA for {{username}}? The user must set up 2FA again to continue using it.": "要重置 {{username}} 的 2FA 吗?该用户必须重新设置 2FA 后才能继续使用。", "Reset 2FA for {{username}}? The user must set up 2FA again to continue using it.": "要重置 {{username}} 的 2FA 吗?该用户必须重新设置 2FA 后才能继续使用。",
"Reset active {{plan}} subscriptions for this user?": "要重置该用户的有效 {{plan}} 订阅吗?",
"Reset all active subscriptions under {{plan}}?": "要重置 {{plan}} 下的所有有效订阅吗?",
"Reset all model prices?": "重置所有模型价格吗?", "Reset all model prices?": "重置所有模型价格吗?",
"Reset all model ratios?": "重置所有模型比例吗?", "Reset all model ratios?": "重置所有模型比例吗?",
"Reset all settings to default values": "将所有设置重置为默认值", "Reset all settings to default values": "将所有设置重置为默认值",
...@@ -3736,8 +3740,10 @@ ...@@ -3736,8 +3740,10 @@
"Reset password": "重置密码", "Reset password": "重置密码",
"Reset Period": "重置周期", "Reset Period": "重置周期",
"Reset prices": "重置价格", "Reset prices": "重置价格",
"Reset quota": "重置额度",
"Reset ratios": "重置比例", "Reset ratios": "重置比例",
"Reset Stats": "重置统计", "Reset Stats": "重置统计",
"Reset subscription quota": "重置订阅额度",
"Reset the user passkey": "重置了用户的通行密钥", "Reset the user passkey": "重置了用户的通行密钥",
"Reset to default": "重置为默认", "Reset to default": "重置为默认",
"Reset to Default": "重置为默认", "Reset to Default": "重置为默认",
...@@ -3929,6 +3935,7 @@ ...@@ -3929,6 +3935,7 @@
"Select a timestamp before clearing logs.": "清除日志前请选择一个时间戳。", "Select a timestamp before clearing logs.": "清除日志前请选择一个时间戳。",
"Select a usage mode to continue": "选择使用模式以继续", "Select a usage mode to continue": "选择使用模式以继续",
"Select a verification method first": "请先选择验证方式", "Select a verification method first": "请先选择验证方式",
"Select active subscription plan": "选择有效订阅套餐",
"Select all": "全选", "Select all": "全选",
"Select all (filtered)": "全选(筛选结果)", "Select all (filtered)": "全选(筛选结果)",
"Select all models": "选择所有模型", "Select all models": "选择所有模型",
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or sign in to comment