fix: address code review issues for RPM limiting feature
- Use TxPipeline (MULTI/EXEC) instead of Pipeline for atomic INCR+EXPIRE - Filter negative values in GetBaseRPM(), update test expectation - Add RPM batch query (GetRPMBatch) to account List API - Add warn logs for RPM increment failures in gateway handler - Reset enableRpmLimit on BulkEditAccountModal close - Use union type 'tiered' | 'sticky_exempt' for rpmStrategy refs - Add design decision comments for rdb.Time() RTT trade-off
This commit is contained in:
@@ -241,9 +241,10 @@ func (h *AccountHandler) List(c *gin.Context) {
|
|||||||
concurrencyCounts = make(map[int64]int)
|
concurrencyCounts = make(map[int64]int)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 识别需要查询窗口费用和会话数的账号(Anthropic OAuth/SetupToken 且启用了相应功能)
|
// 识别需要查询窗口费用、会话数和 RPM 的账号(Anthropic OAuth/SetupToken 且启用了相应功能)
|
||||||
windowCostAccountIDs := make([]int64, 0)
|
windowCostAccountIDs := make([]int64, 0)
|
||||||
sessionLimitAccountIDs := make([]int64, 0)
|
sessionLimitAccountIDs := make([]int64, 0)
|
||||||
|
rpmAccountIDs := make([]int64, 0)
|
||||||
sessionIdleTimeouts := make(map[int64]time.Duration) // 各账号的会话空闲超时配置
|
sessionIdleTimeouts := make(map[int64]time.Duration) // 各账号的会话空闲超时配置
|
||||||
for i := range accounts {
|
for i := range accounts {
|
||||||
acc := &accounts[i]
|
acc := &accounts[i]
|
||||||
@@ -255,12 +256,24 @@ func (h *AccountHandler) List(c *gin.Context) {
|
|||||||
sessionLimitAccountIDs = append(sessionLimitAccountIDs, acc.ID)
|
sessionLimitAccountIDs = append(sessionLimitAccountIDs, acc.ID)
|
||||||
sessionIdleTimeouts[acc.ID] = time.Duration(acc.GetSessionIdleTimeoutMinutes()) * time.Minute
|
sessionIdleTimeouts[acc.ID] = time.Duration(acc.GetSessionIdleTimeoutMinutes()) * time.Minute
|
||||||
}
|
}
|
||||||
|
if acc.GetBaseRPM() > 0 {
|
||||||
|
rpmAccountIDs = append(rpmAccountIDs, acc.ID)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 并行获取窗口费用和活跃会话数
|
// 并行获取窗口费用、活跃会话数和 RPM 计数
|
||||||
var windowCosts map[int64]float64
|
var windowCosts map[int64]float64
|
||||||
var activeSessions map[int64]int
|
var activeSessions map[int64]int
|
||||||
|
var rpmCounts map[int64]int
|
||||||
|
|
||||||
|
// 获取 RPM 计数(批量查询)
|
||||||
|
if len(rpmAccountIDs) > 0 && h.rpmCache != nil {
|
||||||
|
rpmCounts, _ = h.rpmCache.GetRPMBatch(c.Request.Context(), rpmAccountIDs)
|
||||||
|
if rpmCounts == nil {
|
||||||
|
rpmCounts = make(map[int64]int)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 获取活跃会话数(批量查询,传入各账号的 idleTimeout 配置)
|
// 获取活跃会话数(批量查询,传入各账号的 idleTimeout 配置)
|
||||||
if len(sessionLimitAccountIDs) > 0 && h.sessionLimitCache != nil {
|
if len(sessionLimitAccountIDs) > 0 && h.sessionLimitCache != nil {
|
||||||
@@ -321,6 +334,13 @@ func (h *AccountHandler) List(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 添加 RPM 计数(仅当启用时)
|
||||||
|
if rpmCounts != nil {
|
||||||
|
if rpm, ok := rpmCounts[acc.ID]; ok {
|
||||||
|
item.CurrentRPM = &rpm
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
result[i] = item
|
result[i] = item
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -368,8 +368,8 @@ func (h *GatewayHandler) Messages(c *gin.Context) {
|
|||||||
|
|
||||||
// RPM 计数递增(调度成功后、Forward 前)
|
// RPM 计数递增(调度成功后、Forward 前)
|
||||||
if account.IsAnthropicOAuthOrSetupToken() && account.GetBaseRPM() > 0 {
|
if account.IsAnthropicOAuthOrSetupToken() && account.GetBaseRPM() > 0 {
|
||||||
if h.gatewayService.IncrementAccountRPM(c.Request.Context(), account.ID) != nil {
|
if err := h.gatewayService.IncrementAccountRPM(c.Request.Context(), account.ID); err != nil {
|
||||||
// 失败开放:不阻塞请求
|
reqLog.Warn("gateway.rpm_increment_failed", zap.Int64("account_id", account.ID), zap.Error(err))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -558,8 +558,8 @@ func (h *GatewayHandler) Messages(c *gin.Context) {
|
|||||||
|
|
||||||
// RPM 计数递增(调度成功后、Forward 前)
|
// RPM 计数递增(调度成功后、Forward 前)
|
||||||
if account.IsAnthropicOAuthOrSetupToken() && account.GetBaseRPM() > 0 {
|
if account.IsAnthropicOAuthOrSetupToken() && account.GetBaseRPM() > 0 {
|
||||||
if h.gatewayService.IncrementAccountRPM(c.Request.Context(), account.ID) != nil {
|
if err := h.gatewayService.IncrementAccountRPM(c.Request.Context(), account.ID); err != nil {
|
||||||
// 失败开放:不阻塞请求
|
reqLog.Warn("gateway.rpm_increment_failed", zap.Int64("account_id", account.ID), zap.Error(err))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,78 +2,130 @@ package repository
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"strconv"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/Wei-Shaw/sub2api/internal/service"
|
"github.com/Wei-Shaw/sub2api/internal/service"
|
||||||
"github.com/redis/go-redis/v9"
|
"github.com/redis/go-redis/v9"
|
||||||
)
|
)
|
||||||
|
|
||||||
const rpmKeyPrefix = "rpm:"
|
// RPM 计数器缓存常量定义
|
||||||
|
//
|
||||||
|
// 设计说明:
|
||||||
|
// 使用 Redis 简单计数器跟踪每个账号每分钟的请求数:
|
||||||
|
// - Key: rpm:{accountID}:{minuteTimestamp}
|
||||||
|
// - Value: 当前分钟内的请求计数
|
||||||
|
// - TTL: 120 秒(覆盖当前分钟 + 一定冗余)
|
||||||
|
//
|
||||||
|
// 使用 TxPipeline(MULTI/EXEC)执行 INCR + EXPIRE,保证原子性且兼容 Redis Cluster。
|
||||||
|
// 通过 rdb.Time() 获取服务端时间,避免多实例时钟不同步。
|
||||||
|
//
|
||||||
|
// 设计决策:
|
||||||
|
// - TxPipeline vs Pipeline:Pipeline 仅合并发送但不保证原子,TxPipeline 使用 MULTI/EXEC 事务保证原子执行。
|
||||||
|
// - rdb.Time() 单独调用:Pipeline/TxPipeline 中无法引用前一命令的结果,因此 TIME 必须单独调用(2 RTT)。
|
||||||
|
// Lua 脚本可以做到 1 RTT,但在 Redis Cluster 中动态拼接 key 存在 CROSSSLOT 风险,选择安全性优先。
|
||||||
|
const (
|
||||||
|
// RPM 计数器键前缀
|
||||||
|
// 格式: rpm:{accountID}:{minuteTimestamp}
|
||||||
|
rpmKeyPrefix = "rpm:"
|
||||||
|
|
||||||
// Lua scripts use Redis TIME for server-side minute key calculation
|
// RPM 计数器 TTL(120 秒,覆盖当前分钟窗口 + 冗余)
|
||||||
var rpmIncrScript = redis.NewScript(`
|
rpmKeyTTL = 120 * time.Second
|
||||||
local timeResult = redis.call('TIME')
|
)
|
||||||
local minuteKey = math.floor(tonumber(timeResult[1]) / 60)
|
|
||||||
local key = ARGV[1] .. ':' .. minuteKey
|
|
||||||
local count = redis.call('INCR', key)
|
|
||||||
if count == 1 then
|
|
||||||
redis.call('EXPIRE', key, 120)
|
|
||||||
end
|
|
||||||
return count
|
|
||||||
`)
|
|
||||||
|
|
||||||
var rpmGetScript = redis.NewScript(`
|
|
||||||
local timeResult = redis.call('TIME')
|
|
||||||
local minuteKey = math.floor(tonumber(timeResult[1]) / 60)
|
|
||||||
local key = ARGV[1] .. ':' .. minuteKey
|
|
||||||
local count = redis.call('GET', key)
|
|
||||||
if count == false then
|
|
||||||
return 0
|
|
||||||
end
|
|
||||||
return tonumber(count)
|
|
||||||
`)
|
|
||||||
|
|
||||||
|
// RPMCacheImpl RPM 计数器缓存 Redis 实现
|
||||||
type RPMCacheImpl struct {
|
type RPMCacheImpl struct {
|
||||||
rdb *redis.Client
|
rdb *redis.Client
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// NewRPMCache 创建 RPM 计数器缓存
|
||||||
func NewRPMCache(rdb *redis.Client) service.RPMCache {
|
func NewRPMCache(rdb *redis.Client) service.RPMCache {
|
||||||
return &RPMCacheImpl{rdb: rdb}
|
return &RPMCacheImpl{rdb: rdb}
|
||||||
}
|
}
|
||||||
|
|
||||||
func rpmKeyBase(accountID int64) string {
|
// currentMinuteKey 获取当前分钟的完整 Redis key
|
||||||
return fmt.Sprintf("%s%d", rpmKeyPrefix, accountID)
|
// 使用 rdb.Time() 获取 Redis 服务端时间,避免多实例时钟偏差
|
||||||
|
func (c *RPMCacheImpl) currentMinuteKey(ctx context.Context, accountID int64) (string, error) {
|
||||||
|
serverTime, err := c.rdb.Time(ctx).Result()
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("redis TIME: %w", err)
|
||||||
|
}
|
||||||
|
minuteTS := serverTime.Unix() / 60
|
||||||
|
return fmt.Sprintf("%s%d:%d", rpmKeyPrefix, accountID, minuteTS), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// currentMinuteSuffix 获取当前分钟时间戳后缀(供批量操作使用)
|
||||||
|
// 使用 rdb.Time() 获取 Redis 服务端时间
|
||||||
|
func (c *RPMCacheImpl) currentMinuteSuffix(ctx context.Context) (string, error) {
|
||||||
|
serverTime, err := c.rdb.Time(ctx).Result()
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("redis TIME: %w", err)
|
||||||
|
}
|
||||||
|
minuteTS := serverTime.Unix() / 60
|
||||||
|
return strconv.FormatInt(minuteTS, 10), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// IncrementRPM 原子递增并返回当前分钟的计数
|
||||||
|
// 使用 TxPipeline (MULTI/EXEC) 执行 INCR + EXPIRE,保证原子性且兼容 Redis Cluster
|
||||||
func (c *RPMCacheImpl) IncrementRPM(ctx context.Context, accountID int64) (int, error) {
|
func (c *RPMCacheImpl) IncrementRPM(ctx context.Context, accountID int64) (int, error) {
|
||||||
result, err := rpmIncrScript.Run(ctx, c.rdb, nil, rpmKeyBase(accountID)).Int()
|
key, err := c.currentMinuteKey(ctx, accountID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, fmt.Errorf("rpm increment: %w", err)
|
return 0, fmt.Errorf("rpm increment: %w", err)
|
||||||
}
|
}
|
||||||
return result, nil
|
|
||||||
|
// 使用 TxPipeline (MULTI/EXEC) 保证 INCR + EXPIRE 原子执行
|
||||||
|
// EXPIRE 幂等,每次都设置不影响正确性
|
||||||
|
pipe := c.rdb.TxPipeline()
|
||||||
|
incrCmd := pipe.Incr(ctx, key)
|
||||||
|
pipe.Expire(ctx, key, rpmKeyTTL)
|
||||||
|
|
||||||
|
if _, err := pipe.Exec(ctx); err != nil {
|
||||||
|
return 0, fmt.Errorf("rpm increment: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return int(incrCmd.Val()), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetRPM 获取当前分钟的 RPM 计数
|
||||||
func (c *RPMCacheImpl) GetRPM(ctx context.Context, accountID int64) (int, error) {
|
func (c *RPMCacheImpl) GetRPM(ctx context.Context, accountID int64) (int, error) {
|
||||||
result, err := rpmGetScript.Run(ctx, c.rdb, nil, rpmKeyBase(accountID)).Int()
|
key, err := c.currentMinuteKey(ctx, accountID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, fmt.Errorf("rpm get: %w", err)
|
return 0, fmt.Errorf("rpm get: %w", err)
|
||||||
}
|
}
|
||||||
return result, nil
|
|
||||||
|
val, err := c.rdb.Get(ctx, key).Int()
|
||||||
|
if errors.Is(err, redis.Nil) {
|
||||||
|
return 0, nil // 当前分钟无记录
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("rpm get: %w", err)
|
||||||
|
}
|
||||||
|
return val, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetRPMBatch 批量获取多个账号的 RPM 计数(使用 Pipeline)
|
||||||
func (c *RPMCacheImpl) GetRPMBatch(ctx context.Context, accountIDs []int64) (map[int64]int, error) {
|
func (c *RPMCacheImpl) GetRPMBatch(ctx context.Context, accountIDs []int64) (map[int64]int, error) {
|
||||||
if len(accountIDs) == 0 {
|
if len(accountIDs) == 0 {
|
||||||
return map[int64]int{}, nil
|
return map[int64]int{}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
pipe := c.rdb.Pipeline()
|
// 获取当前分钟后缀
|
||||||
cmds := make(map[int64]*redis.Cmd, len(accountIDs))
|
minuteSuffix, err := c.currentMinuteSuffix(ctx)
|
||||||
for _, id := range accountIDs {
|
if err != nil {
|
||||||
cmds[id] = rpmGetScript.Run(ctx, pipe, nil, rpmKeyBase(id))
|
return nil, fmt.Errorf("rpm batch get: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
_, err := pipe.Exec(ctx)
|
// 使用 Pipeline 批量 GET
|
||||||
if err != nil && err != redis.Nil {
|
pipe := c.rdb.Pipeline()
|
||||||
|
cmds := make(map[int64]*redis.StringCmd, len(accountIDs))
|
||||||
|
for _, id := range accountIDs {
|
||||||
|
key := fmt.Sprintf("%s%d:%s", rpmKeyPrefix, id, minuteSuffix)
|
||||||
|
cmds[id] = pipe.Get(ctx, key)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := pipe.Exec(ctx); err != nil && !errors.Is(err, redis.Nil) {
|
||||||
return nil, fmt.Errorf("rpm batch get: %w", err)
|
return nil, fmt.Errorf("rpm batch get: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1138,13 +1138,16 @@ func (a *Account) GetSessionIdleTimeoutMinutes() int {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// GetBaseRPM 获取基础 RPM 限制
|
// GetBaseRPM 获取基础 RPM 限制
|
||||||
// 返回 0 表示未启用
|
// 返回 0 表示未启用(负数视为无效配置,按 0 处理)
|
||||||
func (a *Account) GetBaseRPM() int {
|
func (a *Account) GetBaseRPM() int {
|
||||||
if a.Extra == nil {
|
if a.Extra == nil {
|
||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
if v, ok := a.Extra["base_rpm"]; ok {
|
if v, ok := a.Extra["base_rpm"]; ok {
|
||||||
return parseExtraInt(v)
|
val := parseExtraInt(v)
|
||||||
|
if val > 0 {
|
||||||
|
return val
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,9 @@ func TestGetBaseRPM(t *testing.T) {
|
|||||||
{"zero", map[string]any{"base_rpm": 0}, 0},
|
{"zero", map[string]any{"base_rpm": 0}, 0},
|
||||||
{"int value", map[string]any{"base_rpm": 15}, 15},
|
{"int value", map[string]any{"base_rpm": 15}, 15},
|
||||||
{"float value", map[string]any{"base_rpm": 15.0}, 15},
|
{"float value", map[string]any{"base_rpm": 15.0}, 15},
|
||||||
|
{"string value", map[string]any{"base_rpm": "15"}, 15},
|
||||||
|
{"negative value", map[string]any{"base_rpm": -5}, 0},
|
||||||
|
{"int64 value", map[string]any{"base_rpm": int64(20)}, 20},
|
||||||
}
|
}
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
@@ -35,6 +38,8 @@ func TestGetRPMStrategy(t *testing.T) {
|
|||||||
{"tiered", map[string]any{"rpm_strategy": "tiered"}, "tiered"},
|
{"tiered", map[string]any{"rpm_strategy": "tiered"}, "tiered"},
|
||||||
{"sticky_exempt", map[string]any{"rpm_strategy": "sticky_exempt"}, "sticky_exempt"},
|
{"sticky_exempt", map[string]any{"rpm_strategy": "sticky_exempt"}, "sticky_exempt"},
|
||||||
{"invalid", map[string]any{"rpm_strategy": "foobar"}, "tiered"},
|
{"invalid", map[string]any{"rpm_strategy": "foobar"}, "tiered"},
|
||||||
|
{"empty string fallback", map[string]any{"rpm_strategy": ""}, "tiered"},
|
||||||
|
{"numeric value fallback", map[string]any{"rpm_strategy": 123}, "tiered"},
|
||||||
}
|
}
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
@@ -61,6 +66,13 @@ func TestCheckRPMSchedulability(t *testing.T) {
|
|||||||
{"sticky_exempt over limit", map[string]any{"base_rpm": 15, "rpm_strategy": "sticky_exempt"}, 100, WindowCostStickyOnly},
|
{"sticky_exempt over limit", map[string]any{"base_rpm": 15, "rpm_strategy": "sticky_exempt"}, 100, WindowCostStickyOnly},
|
||||||
{"custom buffer", map[string]any{"base_rpm": 10, "rpm_sticky_buffer": 5}, 14, WindowCostStickyOnly},
|
{"custom buffer", map[string]any{"base_rpm": 10, "rpm_sticky_buffer": 5}, 14, WindowCostStickyOnly},
|
||||||
{"custom buffer red", map[string]any{"base_rpm": 10, "rpm_sticky_buffer": 5}, 15, WindowCostNotSchedulable},
|
{"custom buffer red", map[string]any{"base_rpm": 10, "rpm_sticky_buffer": 5}, 15, WindowCostNotSchedulable},
|
||||||
|
{"base_rpm=1 green", map[string]any{"base_rpm": 1}, 0, WindowCostSchedulable},
|
||||||
|
{"base_rpm=1 yellow (at limit)", map[string]any{"base_rpm": 1}, 1, WindowCostStickyOnly},
|
||||||
|
{"base_rpm=1 red (at limit+buffer)", map[string]any{"base_rpm": 1}, 2, WindowCostNotSchedulable},
|
||||||
|
{"negative currentRPM", map[string]any{"base_rpm": 15}, -1, WindowCostSchedulable},
|
||||||
|
{"base_rpm negative disabled", map[string]any{"base_rpm": -5}, 10, WindowCostSchedulable},
|
||||||
|
{"very high currentRPM", map[string]any{"base_rpm": 10}, 9999, WindowCostNotSchedulable},
|
||||||
|
{"sticky_exempt very high currentRPM", map[string]any{"base_rpm": 10, "rpm_strategy": "sticky_exempt"}, 9999, WindowCostStickyOnly},
|
||||||
}
|
}
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
@@ -71,3 +83,33 @@ func TestCheckRPMSchedulability(t *testing.T) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestGetRPMStickyBuffer(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
extra map[string]any
|
||||||
|
expected int
|
||||||
|
}{
|
||||||
|
{"nil extra", nil, 0},
|
||||||
|
{"no keys", map[string]any{}, 0},
|
||||||
|
{"base_rpm=0", map[string]any{"base_rpm": 0}, 0},
|
||||||
|
{"base_rpm=1 min buffer 1", map[string]any{"base_rpm": 1}, 1},
|
||||||
|
{"base_rpm=4 min buffer 1", map[string]any{"base_rpm": 4}, 1},
|
||||||
|
{"base_rpm=5 buffer 1", map[string]any{"base_rpm": 5}, 1},
|
||||||
|
{"base_rpm=10 buffer 2", map[string]any{"base_rpm": 10}, 2},
|
||||||
|
{"base_rpm=15 buffer 3", map[string]any{"base_rpm": 15}, 3},
|
||||||
|
{"base_rpm=100 buffer 20", map[string]any{"base_rpm": 100}, 20},
|
||||||
|
{"custom buffer=5", map[string]any{"base_rpm": 10, "rpm_sticky_buffer": 5}, 5},
|
||||||
|
{"custom buffer=0 fallback to default", map[string]any{"base_rpm": 10, "rpm_sticky_buffer": 0}, 2},
|
||||||
|
{"custom buffer negative fallback", map[string]any{"base_rpm": 10, "rpm_sticky_buffer": -1}, 2},
|
||||||
|
{"custom buffer with float", map[string]any{"base_rpm": 10, "rpm_sticky_buffer": float64(7)}, 7},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
a := &Account{Extra: tt.extra}
|
||||||
|
if got := a.GetRPMStickyBuffer(); got != tt.expected {
|
||||||
|
t.Errorf("GetRPMStickyBuffer() = %d, want %d", got, tt.expected)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -2708,6 +2708,9 @@ func (s *GatewayService) selectAccountForModelWithPlatform(ctx context.Context,
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 批量预取 RPM 计数,避免逐个账号查询(N+1)
|
||||||
|
ctx = s.withRPMPrefetch(ctx, accounts)
|
||||||
|
|
||||||
// 3. 按优先级+最久未用选择(考虑模型支持)
|
// 3. 按优先级+最久未用选择(考虑模型支持)
|
||||||
var selected *Account
|
var selected *Account
|
||||||
for i := range accounts {
|
for i := range accounts {
|
||||||
@@ -2922,6 +2925,9 @@ func (s *GatewayService) selectAccountWithMixedScheduling(ctx context.Context, g
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 批量预取 RPM 计数,避免逐个账号查询(N+1)
|
||||||
|
ctx = s.withRPMPrefetch(ctx, accounts)
|
||||||
|
|
||||||
// 3. 按优先级+最久未用选择(考虑模型支持和混合调度)
|
// 3. 按优先级+最久未用选择(考虑模型支持和混合调度)
|
||||||
var selected *Account
|
var selected *Account
|
||||||
for i := range accounts {
|
for i := range accounts {
|
||||||
|
|||||||
@@ -68,6 +68,7 @@
|
|||||||
<span class="font-mono">{{ currentRPM }}</span>
|
<span class="font-mono">{{ currentRPM }}</span>
|
||||||
<span class="text-gray-400 dark:text-gray-500">/</span>
|
<span class="text-gray-400 dark:text-gray-500">/</span>
|
||||||
<span class="font-mono">{{ account.base_rpm }}</span>
|
<span class="font-mono">{{ account.base_rpm }}</span>
|
||||||
|
<span class="text-[9px] opacity-60">{{ rpmStrategyTag }}</span>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -143,19 +144,15 @@ const windowCostClass = computed(() => {
|
|||||||
const limit = props.account.window_cost_limit || 0
|
const limit = props.account.window_cost_limit || 0
|
||||||
const reserve = props.account.window_cost_sticky_reserve || 10
|
const reserve = props.account.window_cost_sticky_reserve || 10
|
||||||
|
|
||||||
// >= 阈值+预留: 完全不可调度 (红色)
|
|
||||||
if (current >= limit + reserve) {
|
if (current >= limit + reserve) {
|
||||||
return 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400'
|
return 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400'
|
||||||
}
|
}
|
||||||
// >= 阈值: 仅粘性会话 (橙色)
|
|
||||||
if (current >= limit) {
|
if (current >= limit) {
|
||||||
return 'bg-orange-100 text-orange-700 dark:bg-orange-900/30 dark:text-orange-400'
|
return 'bg-orange-100 text-orange-700 dark:bg-orange-900/30 dark:text-orange-400'
|
||||||
}
|
}
|
||||||
// >= 80% 阈值: 警告 (黄色)
|
|
||||||
if (current >= limit * 0.8) {
|
if (current >= limit * 0.8) {
|
||||||
return 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-400'
|
return 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-400'
|
||||||
}
|
}
|
||||||
// 正常 (绿色)
|
|
||||||
return 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-400'
|
return 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-400'
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -183,15 +180,12 @@ const sessionLimitClass = computed(() => {
|
|||||||
const current = activeSessions.value
|
const current = activeSessions.value
|
||||||
const max = props.account.max_sessions || 0
|
const max = props.account.max_sessions || 0
|
||||||
|
|
||||||
// >= 最大: 完全占满 (红色)
|
|
||||||
if (current >= max) {
|
if (current >= max) {
|
||||||
return 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400'
|
return 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400'
|
||||||
}
|
}
|
||||||
// >= 80%: 警告 (黄色)
|
|
||||||
if (current >= max * 0.8) {
|
if (current >= max * 0.8) {
|
||||||
return 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-400'
|
return 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-400'
|
||||||
}
|
}
|
||||||
// 正常 (绿色)
|
|
||||||
return 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-400'
|
return 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-400'
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -222,35 +216,74 @@ const showRpmLimit = computed(() => {
|
|||||||
// 当前 RPM 计数
|
// 当前 RPM 计数
|
||||||
const currentRPM = computed(() => props.account.current_rpm ?? 0)
|
const currentRPM = computed(() => props.account.current_rpm ?? 0)
|
||||||
|
|
||||||
|
// RPM 策略
|
||||||
|
const rpmStrategy = computed(() => props.account.rpm_strategy || 'tiered')
|
||||||
|
|
||||||
|
// RPM 策略标签
|
||||||
|
const rpmStrategyTag = computed(() => {
|
||||||
|
return rpmStrategy.value === 'sticky_exempt' ? '[S]' : '[T]'
|
||||||
|
})
|
||||||
|
|
||||||
|
// RPM buffer 计算(与后端一致:base <= 0 时 buffer 为 0)
|
||||||
|
const rpmBuffer = computed(() => {
|
||||||
|
const base = props.account.base_rpm || 0
|
||||||
|
return props.account.rpm_sticky_buffer ?? (base > 0 ? Math.max(1, Math.floor(base / 5)) : 0)
|
||||||
|
})
|
||||||
|
|
||||||
// RPM 状态样式
|
// RPM 状态样式
|
||||||
const rpmClass = computed(() => {
|
const rpmClass = computed(() => {
|
||||||
if (!showRpmLimit.value) return ''
|
if (!showRpmLimit.value) return ''
|
||||||
|
|
||||||
const current = currentRPM.value
|
const current = currentRPM.value
|
||||||
const base = props.account.base_rpm ?? 0
|
const base = props.account.base_rpm ?? 0
|
||||||
if (base <= 0) return 'bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-400'
|
const buffer = rpmBuffer.value
|
||||||
|
|
||||||
const strategy = props.account.rpm_strategy || 'tiered'
|
if (rpmStrategy.value === 'tiered') {
|
||||||
if (strategy === 'tiered') {
|
if (current >= base + buffer) {
|
||||||
const buffer = props.account.rpm_sticky_buffer ?? Math.max(1, Math.floor(base / 5))
|
return 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400'
|
||||||
if (current >= base + buffer) return 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400'
|
}
|
||||||
if (current >= base) return 'bg-orange-100 text-orange-700 dark:bg-orange-900/30 dark:text-orange-400'
|
if (current >= base) {
|
||||||
|
return 'bg-orange-100 text-orange-700 dark:bg-orange-900/30 dark:text-orange-400'
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
if (current >= base) return 'bg-orange-100 text-orange-700 dark:bg-orange-900/30 dark:text-orange-400'
|
if (current >= base) {
|
||||||
|
return 'bg-orange-100 text-orange-700 dark:bg-orange-900/30 dark:text-orange-400'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (current >= base * 0.8) {
|
||||||
|
return 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-400'
|
||||||
}
|
}
|
||||||
if (current >= base * 0.8) return 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-400'
|
|
||||||
return 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-400'
|
return 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-400'
|
||||||
})
|
})
|
||||||
|
|
||||||
// RPM 提示文字
|
// RPM 提示文字(增强版:显示策略、区域、缓冲区)
|
||||||
const rpmTooltip = computed(() => {
|
const rpmTooltip = computed(() => {
|
||||||
if (!showRpmLimit.value) return ''
|
if (!showRpmLimit.value) return ''
|
||||||
|
|
||||||
const current = currentRPM.value
|
const current = currentRPM.value
|
||||||
const base = props.account.base_rpm ?? 0
|
const base = props.account.base_rpm ?? 0
|
||||||
if (current >= base) return t('admin.accounts.capacity.rpm.full')
|
const buffer = rpmBuffer.value
|
||||||
if (current >= base * 0.8) return t('admin.accounts.capacity.rpm.warning')
|
|
||||||
return t('admin.accounts.capacity.rpm.normal')
|
if (rpmStrategy.value === 'tiered') {
|
||||||
|
if (current >= base + buffer) {
|
||||||
|
return t('admin.accounts.capacity.rpm.tieredBlocked', { buffer })
|
||||||
|
}
|
||||||
|
if (current >= base) {
|
||||||
|
return t('admin.accounts.capacity.rpm.tieredStickyOnly', { buffer })
|
||||||
|
}
|
||||||
|
if (current >= base * 0.8) {
|
||||||
|
return t('admin.accounts.capacity.rpm.tieredWarning')
|
||||||
|
}
|
||||||
|
return t('admin.accounts.capacity.rpm.tieredNormal')
|
||||||
|
} else {
|
||||||
|
if (current >= base) {
|
||||||
|
return t('admin.accounts.capacity.rpm.stickyExemptOver')
|
||||||
|
}
|
||||||
|
if (current >= base * 0.8) {
|
||||||
|
return t('admin.accounts.capacity.rpm.stickyExemptWarning')
|
||||||
|
}
|
||||||
|
return t('admin.accounts.capacity.rpm.stickyExemptNormal')
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
// 格式化费用显示
|
// 格式化费用显示
|
||||||
|
|||||||
@@ -585,6 +585,111 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- RPM Limit (仅全部为 Anthropic OAuth/SetupToken 时显示) -->
|
||||||
|
<div v-if="allAnthropicOAuthOrSetupToken" class="border-t border-gray-200 pt-4 dark:border-dark-600">
|
||||||
|
<div class="mb-3 flex items-center justify-between">
|
||||||
|
<label
|
||||||
|
id="bulk-edit-rpm-limit-label"
|
||||||
|
class="input-label mb-0"
|
||||||
|
for="bulk-edit-rpm-limit-enabled"
|
||||||
|
>
|
||||||
|
{{ t('admin.accounts.quotaControl.rpmLimit.label') }}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
v-model="enableRpmLimit"
|
||||||
|
id="bulk-edit-rpm-limit-enabled"
|
||||||
|
type="checkbox"
|
||||||
|
aria-controls="bulk-edit-rpm-limit-body"
|
||||||
|
class="rounded border-gray-300 text-primary-600 focus:ring-primary-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
id="bulk-edit-rpm-limit-body"
|
||||||
|
:class="!enableRpmLimit && 'pointer-events-none opacity-50'"
|
||||||
|
role="group"
|
||||||
|
aria-labelledby="bulk-edit-rpm-limit-label"
|
||||||
|
>
|
||||||
|
<div class="mb-3 flex items-center justify-between">
|
||||||
|
<span class="text-sm text-gray-700 dark:text-gray-300">{{ t('admin.accounts.quotaControl.rpmLimit.hint') }}</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
@click="rpmLimitEnabled = !rpmLimitEnabled"
|
||||||
|
:class="[
|
||||||
|
'relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2',
|
||||||
|
rpmLimitEnabled ? 'bg-primary-600' : 'bg-gray-200 dark:bg-dark-600'
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
:class="[
|
||||||
|
'pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out',
|
||||||
|
rpmLimitEnabled ? 'translate-x-5' : 'translate-x-0'
|
||||||
|
]"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="rpmLimitEnabled" class="space-y-3">
|
||||||
|
<div>
|
||||||
|
<label class="input-label text-xs">{{ t('admin.accounts.quotaControl.rpmLimit.baseRpm') }}</label>
|
||||||
|
<input
|
||||||
|
v-model.number="bulkBaseRpm"
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
max="1000"
|
||||||
|
step="1"
|
||||||
|
class="input"
|
||||||
|
:placeholder="t('admin.accounts.quotaControl.rpmLimit.baseRpmPlaceholder')"
|
||||||
|
/>
|
||||||
|
<p class="input-hint">{{ t('admin.accounts.quotaControl.rpmLimit.baseRpmHint') }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="input-label text-xs">{{ t('admin.accounts.quotaControl.rpmLimit.strategy') }}</label>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
@click="bulkRpmStrategy = 'tiered'"
|
||||||
|
:class="[
|
||||||
|
'flex-1 rounded-lg px-3 py-2 text-sm font-medium transition-all',
|
||||||
|
bulkRpmStrategy === 'tiered'
|
||||||
|
? 'bg-primary-100 text-primary-700 dark:bg-primary-900/30 dark:text-primary-400'
|
||||||
|
: 'bg-gray-100 text-gray-600 hover:bg-gray-200 dark:bg-dark-600 dark:text-gray-400 dark:hover:bg-dark-500'
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
{{ t('admin.accounts.quotaControl.rpmLimit.strategyTiered') }}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
@click="bulkRpmStrategy = 'sticky_exempt'"
|
||||||
|
:class="[
|
||||||
|
'flex-1 rounded-lg px-3 py-2 text-sm font-medium transition-all',
|
||||||
|
bulkRpmStrategy === 'sticky_exempt'
|
||||||
|
? 'bg-primary-100 text-primary-700 dark:bg-primary-900/30 dark:text-primary-400'
|
||||||
|
: 'bg-gray-100 text-gray-600 hover:bg-gray-200 dark:bg-dark-600 dark:text-gray-400 dark:hover:bg-dark-500'
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
{{ t('admin.accounts.quotaControl.rpmLimit.strategyStickyExempt') }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="bulkRpmStrategy === 'tiered'">
|
||||||
|
<label class="input-label text-xs">{{ t('admin.accounts.quotaControl.rpmLimit.stickyBuffer') }}</label>
|
||||||
|
<input
|
||||||
|
v-model.number="bulkRpmStickyBuffer"
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
step="1"
|
||||||
|
class="input"
|
||||||
|
:placeholder="t('admin.accounts.quotaControl.rpmLimit.stickyBufferPlaceholder')"
|
||||||
|
/>
|
||||||
|
<p class="input-hint">{{ t('admin.accounts.quotaControl.rpmLimit.stickyBufferHint') }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Groups -->
|
<!-- Groups -->
|
||||||
<div class="border-t border-gray-200 pt-4 dark:border-dark-600">
|
<div class="border-t border-gray-200 pt-4 dark:border-dark-600">
|
||||||
<div class="mb-3 flex items-center justify-between">
|
<div class="mb-3 flex items-center justify-between">
|
||||||
@@ -658,7 +763,7 @@ import { ref, watch, computed } from 'vue'
|
|||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
import { useAppStore } from '@/stores/app'
|
import { useAppStore } from '@/stores/app'
|
||||||
import { adminAPI } from '@/api/admin'
|
import { adminAPI } from '@/api/admin'
|
||||||
import type { Proxy as ProxyConfig, AdminGroup, AccountPlatform } from '@/types'
|
import type { Proxy as ProxyConfig, AdminGroup, AccountPlatform, AccountType } from '@/types'
|
||||||
import BaseDialog from '@/components/common/BaseDialog.vue'
|
import BaseDialog from '@/components/common/BaseDialog.vue'
|
||||||
import Select from '@/components/common/Select.vue'
|
import Select from '@/components/common/Select.vue'
|
||||||
import ProxySelector from '@/components/common/ProxySelector.vue'
|
import ProxySelector from '@/components/common/ProxySelector.vue'
|
||||||
@@ -670,6 +775,7 @@ interface Props {
|
|||||||
show: boolean
|
show: boolean
|
||||||
accountIds: number[]
|
accountIds: number[]
|
||||||
selectedPlatforms: AccountPlatform[]
|
selectedPlatforms: AccountPlatform[]
|
||||||
|
selectedTypes: AccountType[]
|
||||||
proxies: ProxyConfig[]
|
proxies: ProxyConfig[]
|
||||||
groups: AdminGroup[]
|
groups: AdminGroup[]
|
||||||
}
|
}
|
||||||
@@ -686,6 +792,15 @@ const appStore = useAppStore()
|
|||||||
// Platform awareness
|
// Platform awareness
|
||||||
const isMixedPlatform = computed(() => props.selectedPlatforms.length > 1)
|
const isMixedPlatform = computed(() => props.selectedPlatforms.length > 1)
|
||||||
|
|
||||||
|
// 是否全部为 Anthropic OAuth/SetupToken(RPM 配置仅在此条件下显示)
|
||||||
|
const allAnthropicOAuthOrSetupToken = computed(() => {
|
||||||
|
return (
|
||||||
|
props.selectedPlatforms.length === 1 &&
|
||||||
|
props.selectedPlatforms[0] === 'anthropic' &&
|
||||||
|
props.selectedTypes.every(t => t === 'oauth' || t === 'setup-token')
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
const platformModelPrefix: Record<string, string[]> = {
|
const platformModelPrefix: Record<string, string[]> = {
|
||||||
anthropic: ['claude-'],
|
anthropic: ['claude-'],
|
||||||
antigravity: ['claude-', 'gemini-', 'gpt-oss-', 'tab_'],
|
antigravity: ['claude-', 'gemini-', 'gpt-oss-', 'tab_'],
|
||||||
@@ -725,6 +840,7 @@ const enablePriority = ref(false)
|
|||||||
const enableRateMultiplier = ref(false)
|
const enableRateMultiplier = ref(false)
|
||||||
const enableStatus = ref(false)
|
const enableStatus = ref(false)
|
||||||
const enableGroups = ref(false)
|
const enableGroups = ref(false)
|
||||||
|
const enableRpmLimit = ref(false)
|
||||||
|
|
||||||
// State - field values
|
// State - field values
|
||||||
const submitting = ref(false)
|
const submitting = ref(false)
|
||||||
@@ -741,6 +857,10 @@ const priority = ref(1)
|
|||||||
const rateMultiplier = ref(1)
|
const rateMultiplier = ref(1)
|
||||||
const status = ref<'active' | 'inactive'>('active')
|
const status = ref<'active' | 'inactive'>('active')
|
||||||
const groupIds = ref<number[]>([])
|
const groupIds = ref<number[]>([])
|
||||||
|
const rpmLimitEnabled = ref(false)
|
||||||
|
const bulkBaseRpm = ref<number | null>(null)
|
||||||
|
const bulkRpmStrategy = ref<'tiered' | 'sticky_exempt'>('tiered')
|
||||||
|
const bulkRpmStickyBuffer = ref<number | null>(null)
|
||||||
|
|
||||||
// All models list (combined Anthropic + OpenAI + Gemini)
|
// All models list (combined Anthropic + OpenAI + Gemini)
|
||||||
const allModels = [
|
const allModels = [
|
||||||
@@ -1094,6 +1214,22 @@ const buildUpdatePayload = (): Record<string, unknown> | null => {
|
|||||||
updates.credentials = credentials
|
updates.credentials = credentials
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// RPM limit settings (写入 extra 字段)
|
||||||
|
if (enableRpmLimit.value) {
|
||||||
|
const extra: Record<string, unknown> = {}
|
||||||
|
if (rpmLimitEnabled.value && bulkBaseRpm.value != null && bulkBaseRpm.value > 0) {
|
||||||
|
extra.base_rpm = bulkBaseRpm.value
|
||||||
|
extra.rpm_strategy = bulkRpmStrategy.value
|
||||||
|
if (bulkRpmStickyBuffer.value != null && bulkRpmStickyBuffer.value > 0) {
|
||||||
|
extra.rpm_sticky_buffer = bulkRpmStickyBuffer.value
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 关闭 RPM 限制 - 设置 base_rpm 为 0
|
||||||
|
extra.base_rpm = 0
|
||||||
|
}
|
||||||
|
updates.extra = extra
|
||||||
|
}
|
||||||
|
|
||||||
return Object.keys(updates).length > 0 ? updates : null
|
return Object.keys(updates).length > 0 ? updates : null
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1173,6 +1309,7 @@ watch(
|
|||||||
enableRateMultiplier.value = false
|
enableRateMultiplier.value = false
|
||||||
enableStatus.value = false
|
enableStatus.value = false
|
||||||
enableGroups.value = false
|
enableGroups.value = false
|
||||||
|
enableRpmLimit.value = false
|
||||||
|
|
||||||
// Reset all values
|
// Reset all values
|
||||||
baseUrl.value = ''
|
baseUrl.value = ''
|
||||||
@@ -1188,6 +1325,10 @@ watch(
|
|||||||
rateMultiplier.value = 1
|
rateMultiplier.value = 1
|
||||||
status.value = 'active'
|
status.value = 'active'
|
||||||
groupIds.value = []
|
groupIds.value = []
|
||||||
|
rpmLimitEnabled.value = false
|
||||||
|
bulkBaseRpm.value = null
|
||||||
|
bulkRpmStrategy.value = 'tiered'
|
||||||
|
bulkRpmStickyBuffer.value = null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1562,26 +1562,68 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="rpmLimitEnabled" class="grid grid-cols-2 gap-4">
|
<div v-if="rpmLimitEnabled" class="space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<label class="input-label">{{ t('admin.accounts.quotaControl.rpmLimit.baseRpm') }}</label>
|
<label class="input-label">{{ t('admin.accounts.quotaControl.rpmLimit.baseRpm') }}</label>
|
||||||
<input
|
<input
|
||||||
v-model.number="baseRpm"
|
v-model.number="baseRpm"
|
||||||
type="number"
|
type="number"
|
||||||
min="1"
|
min="1"
|
||||||
|
max="1000"
|
||||||
step="1"
|
step="1"
|
||||||
class="input"
|
class="input"
|
||||||
:placeholder="t('admin.accounts.quotaControl.rpmLimit.baseRpmPlaceholder')"
|
:placeholder="t('admin.accounts.quotaControl.rpmLimit.baseRpmPlaceholder')"
|
||||||
/>
|
/>
|
||||||
<p class="input-hint">{{ t('admin.accounts.quotaControl.rpmLimit.baseRpmHint') }}</p>
|
<p class="input-hint">{{ t('admin.accounts.quotaControl.rpmLimit.baseRpmHint') }}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label class="input-label">{{ t('admin.accounts.quotaControl.rpmLimit.strategy') }}</label>
|
<label class="input-label">{{ t('admin.accounts.quotaControl.rpmLimit.strategy') }}</label>
|
||||||
<select v-model="rpmStrategy" class="input">
|
<div class="flex gap-2">
|
||||||
<option value="tiered">{{ t('admin.accounts.quotaControl.rpmLimit.strategyTiered') }}</option>
|
<button
|
||||||
<option value="sticky_exempt">{{ t('admin.accounts.quotaControl.rpmLimit.strategyStickyExempt') }}</option>
|
type="button"
|
||||||
</select>
|
@click="rpmStrategy = 'tiered'"
|
||||||
<p class="input-hint">{{ t('admin.accounts.quotaControl.rpmLimit.strategyHint') }}</p>
|
:class="[
|
||||||
|
'flex-1 rounded-lg px-3 py-2 text-sm font-medium transition-all',
|
||||||
|
rpmStrategy === 'tiered'
|
||||||
|
? 'bg-primary-100 text-primary-700 dark:bg-primary-900/30 dark:text-primary-400'
|
||||||
|
: 'bg-gray-100 text-gray-600 hover:bg-gray-200 dark:bg-dark-600 dark:text-gray-400 dark:hover:bg-dark-500'
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
<div class="text-center">
|
||||||
|
<div>{{ t('admin.accounts.quotaControl.rpmLimit.strategyTiered') }}</div>
|
||||||
|
<div class="mt-0.5 text-[10px] opacity-70">{{ t('admin.accounts.quotaControl.rpmLimit.strategyTieredHint') }}</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
@click="rpmStrategy = 'sticky_exempt'"
|
||||||
|
:class="[
|
||||||
|
'flex-1 rounded-lg px-3 py-2 text-sm font-medium transition-all',
|
||||||
|
rpmStrategy === 'sticky_exempt'
|
||||||
|
? 'bg-primary-100 text-primary-700 dark:bg-primary-900/30 dark:text-primary-400'
|
||||||
|
: 'bg-gray-100 text-gray-600 hover:bg-gray-200 dark:bg-dark-600 dark:text-gray-400 dark:hover:bg-dark-500'
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
<div class="text-center">
|
||||||
|
<div>{{ t('admin.accounts.quotaControl.rpmLimit.strategyStickyExempt') }}</div>
|
||||||
|
<div class="mt-0.5 text-[10px] opacity-70">{{ t('admin.accounts.quotaControl.rpmLimit.strategyStickyExemptHint') }}</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="rpmStrategy === 'tiered'">
|
||||||
|
<label class="input-label">{{ t('admin.accounts.quotaControl.rpmLimit.stickyBuffer') }}</label>
|
||||||
|
<input
|
||||||
|
v-model.number="rpmStickyBuffer"
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
step="1"
|
||||||
|
class="input"
|
||||||
|
:placeholder="t('admin.accounts.quotaControl.rpmLimit.stickyBufferPlaceholder')"
|
||||||
|
/>
|
||||||
|
<p class="input-hint">{{ t('admin.accounts.quotaControl.rpmLimit.stickyBufferHint') }}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -2445,7 +2487,8 @@ const maxSessions = ref<number | null>(null)
|
|||||||
const sessionIdleTimeout = ref<number | null>(null)
|
const sessionIdleTimeout = ref<number | null>(null)
|
||||||
const rpmLimitEnabled = ref(false)
|
const rpmLimitEnabled = ref(false)
|
||||||
const baseRpm = ref<number | null>(null)
|
const baseRpm = ref<number | null>(null)
|
||||||
const rpmStrategy = ref<string>('tiered')
|
const rpmStrategy = ref<'tiered' | 'sticky_exempt'>('tiered')
|
||||||
|
const rpmStickyBuffer = ref<number | null>(null)
|
||||||
const tlsFingerprintEnabled = ref(false)
|
const tlsFingerprintEnabled = ref(false)
|
||||||
const sessionIdMaskingEnabled = ref(false)
|
const sessionIdMaskingEnabled = ref(false)
|
||||||
const cacheTTLOverrideEnabled = ref(false)
|
const cacheTTLOverrideEnabled = ref(false)
|
||||||
@@ -3073,6 +3116,7 @@ const resetForm = () => {
|
|||||||
rpmLimitEnabled.value = false
|
rpmLimitEnabled.value = false
|
||||||
baseRpm.value = null
|
baseRpm.value = null
|
||||||
rpmStrategy.value = 'tiered'
|
rpmStrategy.value = 'tiered'
|
||||||
|
rpmStickyBuffer.value = null
|
||||||
tlsFingerprintEnabled.value = false
|
tlsFingerprintEnabled.value = false
|
||||||
sessionIdMaskingEnabled.value = false
|
sessionIdMaskingEnabled.value = false
|
||||||
cacheTTLOverrideEnabled.value = false
|
cacheTTLOverrideEnabled.value = false
|
||||||
@@ -3986,6 +4030,9 @@ const handleAnthropicExchange = async (authCode: string) => {
|
|||||||
if (rpmLimitEnabled.value && baseRpm.value != null && baseRpm.value > 0) {
|
if (rpmLimitEnabled.value && baseRpm.value != null && baseRpm.value > 0) {
|
||||||
extra.base_rpm = baseRpm.value
|
extra.base_rpm = baseRpm.value
|
||||||
extra.rpm_strategy = rpmStrategy.value
|
extra.rpm_strategy = rpmStrategy.value
|
||||||
|
if (rpmStickyBuffer.value != null && rpmStickyBuffer.value > 0) {
|
||||||
|
extra.rpm_sticky_buffer = rpmStickyBuffer.value
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add TLS fingerprint settings
|
// Add TLS fingerprint settings
|
||||||
@@ -4090,6 +4137,9 @@ const handleCookieAuth = async (sessionKey: string) => {
|
|||||||
if (rpmLimitEnabled.value && baseRpm.value != null && baseRpm.value > 0) {
|
if (rpmLimitEnabled.value && baseRpm.value != null && baseRpm.value > 0) {
|
||||||
extra.base_rpm = baseRpm.value
|
extra.base_rpm = baseRpm.value
|
||||||
extra.rpm_strategy = rpmStrategy.value
|
extra.rpm_strategy = rpmStrategy.value
|
||||||
|
if (rpmStickyBuffer.value != null && rpmStickyBuffer.value > 0) {
|
||||||
|
extra.rpm_sticky_buffer = rpmStickyBuffer.value
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add TLS fingerprint settings
|
// Add TLS fingerprint settings
|
||||||
|
|||||||
@@ -972,26 +972,68 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="rpmLimitEnabled" class="grid grid-cols-2 gap-4">
|
<div v-if="rpmLimitEnabled" class="space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<label class="input-label">{{ t('admin.accounts.quotaControl.rpmLimit.baseRpm') }}</label>
|
<label class="input-label">{{ t('admin.accounts.quotaControl.rpmLimit.baseRpm') }}</label>
|
||||||
<input
|
<input
|
||||||
v-model.number="baseRpm"
|
v-model.number="baseRpm"
|
||||||
type="number"
|
type="number"
|
||||||
min="1"
|
min="1"
|
||||||
|
max="1000"
|
||||||
step="1"
|
step="1"
|
||||||
class="input"
|
class="input"
|
||||||
:placeholder="t('admin.accounts.quotaControl.rpmLimit.baseRpmPlaceholder')"
|
:placeholder="t('admin.accounts.quotaControl.rpmLimit.baseRpmPlaceholder')"
|
||||||
/>
|
/>
|
||||||
<p class="input-hint">{{ t('admin.accounts.quotaControl.rpmLimit.baseRpmHint') }}</p>
|
<p class="input-hint">{{ t('admin.accounts.quotaControl.rpmLimit.baseRpmHint') }}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label class="input-label">{{ t('admin.accounts.quotaControl.rpmLimit.strategy') }}</label>
|
<label class="input-label">{{ t('admin.accounts.quotaControl.rpmLimit.strategy') }}</label>
|
||||||
<select v-model="rpmStrategy" class="input">
|
<div class="flex gap-2">
|
||||||
<option value="tiered">{{ t('admin.accounts.quotaControl.rpmLimit.strategyTiered') }}</option>
|
<button
|
||||||
<option value="sticky_exempt">{{ t('admin.accounts.quotaControl.rpmLimit.strategyStickyExempt') }}</option>
|
type="button"
|
||||||
</select>
|
@click="rpmStrategy = 'tiered'"
|
||||||
<p class="input-hint">{{ t('admin.accounts.quotaControl.rpmLimit.strategyHint') }}</p>
|
:class="[
|
||||||
|
'flex-1 rounded-lg px-3 py-2 text-sm font-medium transition-all',
|
||||||
|
rpmStrategy === 'tiered'
|
||||||
|
? 'bg-primary-100 text-primary-700 dark:bg-primary-900/30 dark:text-primary-400'
|
||||||
|
: 'bg-gray-100 text-gray-600 hover:bg-gray-200 dark:bg-dark-600 dark:text-gray-400 dark:hover:bg-dark-500'
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
<div class="text-center">
|
||||||
|
<div>{{ t('admin.accounts.quotaControl.rpmLimit.strategyTiered') }}</div>
|
||||||
|
<div class="mt-0.5 text-[10px] opacity-70">{{ t('admin.accounts.quotaControl.rpmLimit.strategyTieredHint') }}</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
@click="rpmStrategy = 'sticky_exempt'"
|
||||||
|
:class="[
|
||||||
|
'flex-1 rounded-lg px-3 py-2 text-sm font-medium transition-all',
|
||||||
|
rpmStrategy === 'sticky_exempt'
|
||||||
|
? 'bg-primary-100 text-primary-700 dark:bg-primary-900/30 dark:text-primary-400'
|
||||||
|
: 'bg-gray-100 text-gray-600 hover:bg-gray-200 dark:bg-dark-600 dark:text-gray-400 dark:hover:bg-dark-500'
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
<div class="text-center">
|
||||||
|
<div>{{ t('admin.accounts.quotaControl.rpmLimit.strategyStickyExempt') }}</div>
|
||||||
|
<div class="mt-0.5 text-[10px] opacity-70">{{ t('admin.accounts.quotaControl.rpmLimit.strategyStickyExemptHint') }}</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="rpmStrategy === 'tiered'">
|
||||||
|
<label class="input-label">{{ t('admin.accounts.quotaControl.rpmLimit.stickyBuffer') }}</label>
|
||||||
|
<input
|
||||||
|
v-model.number="rpmStickyBuffer"
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
step="1"
|
||||||
|
class="input"
|
||||||
|
:placeholder="t('admin.accounts.quotaControl.rpmLimit.stickyBufferPlaceholder')"
|
||||||
|
/>
|
||||||
|
<p class="input-hint">{{ t('admin.accounts.quotaControl.rpmLimit.stickyBufferHint') }}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1303,7 +1345,8 @@ const maxSessions = ref<number | null>(null)
|
|||||||
const sessionIdleTimeout = ref<number | null>(null)
|
const sessionIdleTimeout = ref<number | null>(null)
|
||||||
const rpmLimitEnabled = ref(false)
|
const rpmLimitEnabled = ref(false)
|
||||||
const baseRpm = ref<number | null>(null)
|
const baseRpm = ref<number | null>(null)
|
||||||
const rpmStrategy = ref<string>('tiered')
|
const rpmStrategy = ref<'tiered' | 'sticky_exempt'>('tiered')
|
||||||
|
const rpmStickyBuffer = ref<number | null>(null)
|
||||||
const tlsFingerprintEnabled = ref(false)
|
const tlsFingerprintEnabled = ref(false)
|
||||||
const sessionIdMaskingEnabled = ref(false)
|
const sessionIdMaskingEnabled = ref(false)
|
||||||
const cacheTTLOverrideEnabled = ref(false)
|
const cacheTTLOverrideEnabled = ref(false)
|
||||||
@@ -1766,6 +1809,7 @@ function loadQuotaControlSettings(account: Account) {
|
|||||||
rpmLimitEnabled.value = false
|
rpmLimitEnabled.value = false
|
||||||
baseRpm.value = null
|
baseRpm.value = null
|
||||||
rpmStrategy.value = 'tiered'
|
rpmStrategy.value = 'tiered'
|
||||||
|
rpmStickyBuffer.value = null
|
||||||
tlsFingerprintEnabled.value = false
|
tlsFingerprintEnabled.value = false
|
||||||
sessionIdMaskingEnabled.value = false
|
sessionIdMaskingEnabled.value = false
|
||||||
cacheTTLOverrideEnabled.value = false
|
cacheTTLOverrideEnabled.value = false
|
||||||
@@ -1793,7 +1837,8 @@ function loadQuotaControlSettings(account: Account) {
|
|||||||
if (account.base_rpm != null && account.base_rpm > 0) {
|
if (account.base_rpm != null && account.base_rpm > 0) {
|
||||||
rpmLimitEnabled.value = true
|
rpmLimitEnabled.value = true
|
||||||
baseRpm.value = account.base_rpm
|
baseRpm.value = account.base_rpm
|
||||||
rpmStrategy.value = account.rpm_strategy || 'tiered'
|
rpmStrategy.value = (account.rpm_strategy as 'tiered' | 'sticky_exempt') || 'tiered'
|
||||||
|
rpmStickyBuffer.value = account.rpm_sticky_buffer ?? null
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load TLS fingerprint setting
|
// Load TLS fingerprint setting
|
||||||
@@ -2110,6 +2155,11 @@ const handleSubmit = async () => {
|
|||||||
if (rpmLimitEnabled.value && baseRpm.value != null && baseRpm.value > 0) {
|
if (rpmLimitEnabled.value && baseRpm.value != null && baseRpm.value > 0) {
|
||||||
newExtra.base_rpm = baseRpm.value
|
newExtra.base_rpm = baseRpm.value
|
||||||
newExtra.rpm_strategy = rpmStrategy.value
|
newExtra.rpm_strategy = rpmStrategy.value
|
||||||
|
if (rpmStickyBuffer.value != null && rpmStickyBuffer.value > 0) {
|
||||||
|
newExtra.rpm_sticky_buffer = rpmStickyBuffer.value
|
||||||
|
} else {
|
||||||
|
delete newExtra.rpm_sticky_buffer
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
delete newExtra.base_rpm
|
delete newExtra.base_rpm
|
||||||
delete newExtra.rpm_strategy
|
delete newExtra.rpm_strategy
|
||||||
|
|||||||
@@ -1618,6 +1618,13 @@ export default {
|
|||||||
full: 'RPM limit reached',
|
full: 'RPM limit reached',
|
||||||
warning: 'RPM approaching limit',
|
warning: 'RPM approaching limit',
|
||||||
normal: 'RPM normal',
|
normal: 'RPM normal',
|
||||||
|
tieredNormal: 'RPM limit (Tiered) - Normal',
|
||||||
|
tieredWarning: 'RPM limit (Tiered) - Approaching limit',
|
||||||
|
tieredStickyOnly: 'RPM limit (Tiered) - Sticky only | Buffer: {buffer}',
|
||||||
|
tieredBlocked: 'RPM limit (Tiered) - Blocked | Buffer: {buffer}',
|
||||||
|
stickyExemptNormal: 'RPM limit (Sticky Exempt) - Normal',
|
||||||
|
stickyExemptWarning: 'RPM limit (Sticky Exempt) - Approaching limit',
|
||||||
|
stickyExemptOver: 'RPM limit (Sticky Exempt) - Over limit, sticky only'
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
tempUnschedulable: {
|
tempUnschedulable: {
|
||||||
@@ -1842,7 +1849,12 @@ export default {
|
|||||||
strategy: 'RPM Strategy',
|
strategy: 'RPM Strategy',
|
||||||
strategyTiered: 'Tiered Model',
|
strategyTiered: 'Tiered Model',
|
||||||
strategyStickyExempt: 'Sticky Exempt',
|
strategyStickyExempt: 'Sticky Exempt',
|
||||||
|
strategyTieredHint: 'Green → Yellow → Sticky only → Blocked, progressive throttling',
|
||||||
|
strategyStickyExemptHint: 'Only sticky sessions allowed when over limit',
|
||||||
strategyHint: 'Tiered: gradually restrict when exceeded; Sticky Exempt: existing sessions unrestricted',
|
strategyHint: 'Tiered: gradually restrict when exceeded; Sticky Exempt: existing sessions unrestricted',
|
||||||
|
stickyBuffer: 'Sticky Buffer',
|
||||||
|
stickyBufferPlaceholder: 'Default: 20% of base RPM',
|
||||||
|
stickyBufferHint: 'Extra requests allowed for sticky sessions after exceeding base RPM. Leave empty to use default (20% of base RPM, min 1)'
|
||||||
},
|
},
|
||||||
tlsFingerprint: {
|
tlsFingerprint: {
|
||||||
label: 'TLS Fingerprint Simulation',
|
label: 'TLS Fingerprint Simulation',
|
||||||
|
|||||||
@@ -1669,6 +1669,13 @@ export default {
|
|||||||
full: '已达 RPM 上限',
|
full: '已达 RPM 上限',
|
||||||
warning: 'RPM 接近上限',
|
warning: 'RPM 接近上限',
|
||||||
normal: 'RPM 正常',
|
normal: 'RPM 正常',
|
||||||
|
tieredNormal: 'RPM 限制 (三区模型) - 正常',
|
||||||
|
tieredWarning: 'RPM 限制 (三区模型) - 接近阈值',
|
||||||
|
tieredStickyOnly: 'RPM 限制 (三区模型) - 仅粘性会话 | 缓冲区: {buffer}',
|
||||||
|
tieredBlocked: 'RPM 限制 (三区模型) - 已阻塞 | 缓冲区: {buffer}',
|
||||||
|
stickyExemptNormal: 'RPM 限制 (粘性豁免) - 正常',
|
||||||
|
stickyExemptWarning: 'RPM 限制 (粘性豁免) - 接近阈值',
|
||||||
|
stickyExemptOver: 'RPM 限制 (粘性豁免) - 超限,仅粘性会话'
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
clearRateLimit: '清除速率限制',
|
clearRateLimit: '清除速率限制',
|
||||||
@@ -1985,7 +1992,12 @@ export default {
|
|||||||
strategy: 'RPM 策略',
|
strategy: 'RPM 策略',
|
||||||
strategyTiered: '三区模型',
|
strategyTiered: '三区模型',
|
||||||
strategyStickyExempt: '粘性豁免',
|
strategyStickyExempt: '粘性豁免',
|
||||||
|
strategyTieredHint: '绿区→黄区→仅粘性→阻塞,逐步限流',
|
||||||
|
strategyStickyExemptHint: '超限后仅允许粘性会话',
|
||||||
strategyHint: '三区模型: 超限后逐步限制; 粘性豁免: 已有会话不受限',
|
strategyHint: '三区模型: 超限后逐步限制; 粘性豁免: 已有会话不受限',
|
||||||
|
stickyBuffer: '粘性缓冲区',
|
||||||
|
stickyBufferPlaceholder: '默认: base RPM 的 20%',
|
||||||
|
stickyBufferHint: '超过 base RPM 后,粘性会话额外允许的请求数。为空则使用默认值(base RPM 的 20%,最小为 1)'
|
||||||
},
|
},
|
||||||
tlsFingerprint: {
|
tlsFingerprint: {
|
||||||
label: 'TLS 指纹模拟',
|
label: 'TLS 指纹模拟',
|
||||||
|
|||||||
@@ -263,7 +263,7 @@
|
|||||||
<AccountActionMenu :show="menu.show" :account="menu.acc" :position="menu.pos" @close="menu.show = false" @test="handleTest" @stats="handleViewStats" @reauth="handleReAuth" @refresh-token="handleRefresh" @reset-status="handleResetStatus" @clear-rate-limit="handleClearRateLimit" />
|
<AccountActionMenu :show="menu.show" :account="menu.acc" :position="menu.pos" @close="menu.show = false" @test="handleTest" @stats="handleViewStats" @reauth="handleReAuth" @refresh-token="handleRefresh" @reset-status="handleResetStatus" @clear-rate-limit="handleClearRateLimit" />
|
||||||
<SyncFromCrsModal :show="showSync" @close="showSync = false" @synced="reload" />
|
<SyncFromCrsModal :show="showSync" @close="showSync = false" @synced="reload" />
|
||||||
<ImportDataModal :show="showImportData" @close="showImportData = false" @imported="handleDataImported" />
|
<ImportDataModal :show="showImportData" @close="showImportData = false" @imported="handleDataImported" />
|
||||||
<BulkEditAccountModal :show="showBulkEdit" :account-ids="selIds" :selected-platforms="selPlatforms" :proxies="proxies" :groups="groups" @close="showBulkEdit = false" @updated="handleBulkUpdated" />
|
<BulkEditAccountModal :show="showBulkEdit" :account-ids="selIds" :selected-platforms="selPlatforms" :selected-types="selTypes" :proxies="proxies" :groups="groups" @close="showBulkEdit = false" @updated="handleBulkUpdated" />
|
||||||
<TempUnschedStatusModal :show="showTempUnsched" :account="tempUnschedAcc" @close="showTempUnsched = false" @reset="handleTempUnschedReset" />
|
<TempUnschedStatusModal :show="showTempUnsched" :account="tempUnschedAcc" @close="showTempUnsched = false" @reset="handleTempUnschedReset" />
|
||||||
<ConfirmDialog :show="showDeleteDialog" :title="t('admin.accounts.deleteAccount')" :message="t('admin.accounts.deleteConfirm', { name: deletingAcc?.name })" :confirm-text="t('common.delete')" :cancel-text="t('common.cancel')" :danger="true" @confirm="confirmDelete" @cancel="showDeleteDialog = false" />
|
<ConfirmDialog :show="showDeleteDialog" :title="t('admin.accounts.deleteAccount')" :message="t('admin.accounts.deleteConfirm', { name: deletingAcc?.name })" :confirm-text="t('common.delete')" :cancel-text="t('common.cancel')" :danger="true" @confirm="confirmDelete" @cancel="showDeleteDialog = false" />
|
||||||
<ConfirmDialog :show="showExportDataDialog" :title="t('admin.accounts.dataExport')" :message="t('admin.accounts.dataExportConfirmMessage')" :confirm-text="t('admin.accounts.dataExportConfirm')" :cancel-text="t('common.cancel')" @confirm="handleExportData" @cancel="showExportDataDialog = false">
|
<ConfirmDialog :show="showExportDataDialog" :title="t('admin.accounts.dataExport')" :message="t('admin.accounts.dataExportConfirmMessage')" :confirm-text="t('admin.accounts.dataExportConfirm')" :cancel-text="t('common.cancel')" @confirm="handleExportData" @cancel="showExportDataDialog = false">
|
||||||
@@ -307,7 +307,7 @@ import PlatformTypeBadge from '@/components/common/PlatformTypeBadge.vue'
|
|||||||
import Icon from '@/components/icons/Icon.vue'
|
import Icon from '@/components/icons/Icon.vue'
|
||||||
import ErrorPassthroughRulesModal from '@/components/admin/ErrorPassthroughRulesModal.vue'
|
import ErrorPassthroughRulesModal from '@/components/admin/ErrorPassthroughRulesModal.vue'
|
||||||
import { formatDateTime, formatRelativeTime } from '@/utils/format'
|
import { formatDateTime, formatRelativeTime } from '@/utils/format'
|
||||||
import type { Account, AccountPlatform, Proxy, AdminGroup, WindowStats } from '@/types'
|
import type { Account, AccountPlatform, AccountType, Proxy, AdminGroup, WindowStats } from '@/types'
|
||||||
|
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
const appStore = useAppStore()
|
const appStore = useAppStore()
|
||||||
@@ -324,6 +324,14 @@ const selPlatforms = computed<AccountPlatform[]>(() => {
|
|||||||
)
|
)
|
||||||
return [...platforms]
|
return [...platforms]
|
||||||
})
|
})
|
||||||
|
const selTypes = computed<AccountType[]>(() => {
|
||||||
|
const types = new Set(
|
||||||
|
accounts.value
|
||||||
|
.filter(a => selIds.value.includes(a.id))
|
||||||
|
.map(a => a.type)
|
||||||
|
)
|
||||||
|
return [...types]
|
||||||
|
})
|
||||||
const showCreate = ref(false)
|
const showCreate = ref(false)
|
||||||
const showEdit = ref(false)
|
const showEdit = ref(false)
|
||||||
const showSync = ref(false)
|
const showSync = ref(false)
|
||||||
|
|||||||
Reference in New Issue
Block a user