From 1ee17383f87ae86138b4bc237c27f190f545a57c Mon Sep 17 00:00:00 2001 From: erio Date: Sat, 7 Mar 2026 19:06:59 +0800 Subject: [PATCH 1/4] feat(account): add daily/weekly periodic quota limits for API Key accounts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extend the existing total quota limit with daily and weekly periodic dimensions. Each dimension is independently configurable and uses lazy reset — when the period expires, usage is automatically reset to zero on the next increment. Any dimension exceeding its limit will pause the account from scheduling. Backend: - Add GetQuotaDailyLimit/Used, GetQuotaWeeklyLimit/Used, HasAnyQuotaLimit - Rewrite IncrementQuotaUsed with atomic CTE SQL for 3-dimension update - Rewrite ResetQuotaUsed to clear all dimensions and period timestamps - Update postUsageBilling to use HasAnyQuotaLimit() - Preserve daily/weekly used values on account edit Frontend: - Refactor QuotaLimitCard from single v-model to 3-dimension props - Add QuotaBadge component for compact D/W/$ display - Update AccountCapacityCell with per-dimension badges - Update Create/Edit modals with daily/weekly quota fields - Update AccountActionMenu hasQuotaLimit to check all dimensions - Add i18n strings for daily/weekly/total quota labels Co-Authored-By: Claude Opus 4.6 --- backend/internal/handler/dto/mappers.go | 20 +++- backend/internal/handler/dto/types.go | 8 +- backend/internal/repository/account_repo.go | 57 +++++++-- backend/internal/service/account.go | 92 ++++++++++++--- backend/internal/service/account_service.go | 4 +- backend/internal/service/admin_service.go | 8 +- backend/internal/service/gateway_service.go | 2 +- .../account/AccountCapacityCell.vue | 63 ++-------- .../components/account/CreateAccountModal.vue | 39 +++++- .../components/account/EditAccountModal.vue | 35 +++++- .../src/components/account/QuotaBadge.vue | 49 ++++++++ .../src/components/account/QuotaLimitCard.vue | 111 ++++++++++++------ .../admin/account/AccountActionMenu.vue | 9 +- frontend/src/i18n/locales/en.ts | 12 +- frontend/src/i18n/locales/zh.ts | 12 +- frontend/src/types/index.ts | 4 + 16 files changed, 386 insertions(+), 139 deletions(-) create mode 100644 frontend/src/components/account/QuotaBadge.vue diff --git a/backend/internal/handler/dto/mappers.go b/backend/internal/handler/dto/mappers.go index 2cae9817..83bf9f38 100644 --- a/backend/internal/handler/dto/mappers.go +++ b/backend/internal/handler/dto/mappers.go @@ -125,9 +125,9 @@ func GroupFromServiceAdmin(g *service.Group) *AdminGroup { Group: groupFromServiceBase(g), ModelRouting: g.ModelRouting, ModelRoutingEnabled: g.ModelRoutingEnabled, - MCPXMLInject: g.MCPXMLInject, - DefaultMappedModel: g.DefaultMappedModel, - SupportedModelScopes: g.SupportedModelScopes, + MCPXMLInject: g.MCPXMLInject, + DefaultMappedModel: g.DefaultMappedModel, + SupportedModelScopes: g.SupportedModelScopes, AccountCount: g.AccountCount, SortOrder: g.SortOrder, } @@ -255,11 +255,19 @@ func AccountFromServiceShallow(a *service.Account) *Account { if a.Type == service.AccountTypeAPIKey { if limit := a.GetQuotaLimit(); limit > 0 { out.QuotaLimit = &limit - } - used := a.GetQuotaUsed() - if out.QuotaLimit != nil { + used := a.GetQuotaUsed() out.QuotaUsed = &used } + if limit := a.GetQuotaDailyLimit(); limit > 0 { + out.QuotaDailyLimit = &limit + used := a.GetQuotaDailyUsed() + out.QuotaDailyUsed = &used + } + if limit := a.GetQuotaWeeklyLimit(); limit > 0 { + out.QuotaWeeklyLimit = &limit + used := a.GetQuotaWeeklyUsed() + out.QuotaWeeklyUsed = &used + } } return out diff --git a/backend/internal/handler/dto/types.go b/backend/internal/handler/dto/types.go index 1c68f429..a11ea390 100644 --- a/backend/internal/handler/dto/types.go +++ b/backend/internal/handler/dto/types.go @@ -193,8 +193,12 @@ type Account struct { CacheTTLOverrideTarget *string `json:"cache_ttl_override_target,omitempty"` // API Key 账号配额限制 - QuotaLimit *float64 `json:"quota_limit,omitempty"` - QuotaUsed *float64 `json:"quota_used,omitempty"` + QuotaLimit *float64 `json:"quota_limit,omitempty"` + QuotaUsed *float64 `json:"quota_used,omitempty"` + QuotaDailyLimit *float64 `json:"quota_daily_limit,omitempty"` + QuotaDailyUsed *float64 `json:"quota_daily_used,omitempty"` + QuotaWeeklyLimit *float64 `json:"quota_weekly_limit,omitempty"` + QuotaWeeklyUsed *float64 `json:"quota_weekly_used,omitempty"` Proxy *Proxy `json:"proxy,omitempty"` AccountGroups []AccountGroup `json:"account_groups,omitempty"` diff --git a/backend/internal/repository/account_repo.go b/backend/internal/repository/account_repo.go index ffbfd466..2e4c7ec9 100644 --- a/backend/internal/repository/account_repo.go +++ b/backend/internal/repository/account_repo.go @@ -1676,13 +1676,47 @@ func (r *accountRepository) FindByExtraField(ctx context.Context, key string, va return r.accountsToService(ctx, accounts) } -// IncrementQuotaUsed 原子递增账号的 extra.quota_used 字段 +// nowUTC is a SQL expression to generate a UTC RFC3339 timestamp string. +const nowUTC = `to_char(NOW() AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS.US"Z"')` + +// IncrementQuotaUsed 原子递增账号的配额用量(总/日/周三个维度) +// 日/周额度在周期过期时自动重置为 0 再递增。 func (r *accountRepository) IncrementQuotaUsed(ctx context.Context, id int64, amount float64) error { rows, err := r.sql.QueryContext(ctx, - `UPDATE accounts SET extra = jsonb_set( - COALESCE(extra, '{}'::jsonb), - '{quota_used}', - to_jsonb(COALESCE((extra->>'quota_used')::numeric, 0) + $1) + `UPDATE accounts SET extra = ( + COALESCE(extra, '{}'::jsonb) + -- 总额度:始终递增 + || jsonb_build_object('quota_used', COALESCE((extra->>'quota_used')::numeric, 0) + $1) + -- 日额度:仅在 quota_daily_limit > 0 时处理 + || CASE WHEN COALESCE((extra->>'quota_daily_limit')::numeric, 0) > 0 THEN + jsonb_build_object( + 'quota_daily_used', + CASE WHEN COALESCE((extra->>'quota_daily_start')::timestamptz, '1970-01-01'::timestamptz) + + '24 hours'::interval <= NOW() + THEN $1 + ELSE COALESCE((extra->>'quota_daily_used')::numeric, 0) + $1 END, + 'quota_daily_start', + CASE WHEN COALESCE((extra->>'quota_daily_start')::timestamptz, '1970-01-01'::timestamptz) + + '24 hours'::interval <= NOW() + THEN `+nowUTC+` + ELSE COALESCE(extra->>'quota_daily_start', `+nowUTC+`) END + ) + ELSE '{}'::jsonb END + -- 周额度:仅在 quota_weekly_limit > 0 时处理 + || CASE WHEN COALESCE((extra->>'quota_weekly_limit')::numeric, 0) > 0 THEN + jsonb_build_object( + 'quota_weekly_used', + CASE WHEN COALESCE((extra->>'quota_weekly_start')::timestamptz, '1970-01-01'::timestamptz) + + '168 hours'::interval <= NOW() + THEN $1 + ELSE COALESCE((extra->>'quota_weekly_used')::numeric, 0) + $1 END, + 'quota_weekly_start', + CASE WHEN COALESCE((extra->>'quota_weekly_start')::timestamptz, '1970-01-01'::timestamptz) + + '168 hours'::interval <= NOW() + THEN `+nowUTC+` + ELSE COALESCE(extra->>'quota_weekly_start', `+nowUTC+`) END + ) + ELSE '{}'::jsonb END ), updated_at = NOW() WHERE id = $2 AND deleted_at IS NULL RETURNING @@ -1704,7 +1738,7 @@ func (r *accountRepository) IncrementQuotaUsed(ctx context.Context, id int64, am return err } - // 配额刚超限时触发调度快照刷新,使账号及时从调度候选中移除 + // 任一维度配额刚超限时触发调度快照刷新 if limit > 0 && newUsed >= limit && (newUsed-amount) < limit { if err := enqueueSchedulerOutbox(ctx, r.sql, service.SchedulerOutboxEventAccountChanged, &id, nil, nil); err != nil { logger.LegacyPrintf("repository.account", "[SchedulerOutbox] enqueue quota exceeded failed: account=%d err=%v", id, err) @@ -1713,14 +1747,13 @@ func (r *accountRepository) IncrementQuotaUsed(ctx context.Context, id int64, am return nil } -// ResetQuotaUsed 重置账号的 extra.quota_used 为 0 +// ResetQuotaUsed 重置账号所有维度的配额用量为 0 func (r *accountRepository) ResetQuotaUsed(ctx context.Context, id int64) error { _, err := r.sql.ExecContext(ctx, - `UPDATE accounts SET extra = jsonb_set( - COALESCE(extra, '{}'::jsonb), - '{quota_used}', - '0'::jsonb - ), updated_at = NOW() + `UPDATE accounts SET extra = ( + COALESCE(extra, '{}'::jsonb) + || '{"quota_used": 0, "quota_daily_used": 0, "quota_weekly_used": 0}'::jsonb + ) - 'quota_daily_start' - 'quota_weekly_start', updated_at = NOW() WHERE id = $1 AND deleted_at IS NULL`, id) if err != nil { diff --git a/backend/internal/service/account.go b/backend/internal/service/account.go index 8eb3748c..fdef2f6c 100644 --- a/backend/internal/service/account.go +++ b/backend/internal/service/account.go @@ -1134,33 +1134,97 @@ func (a *Account) GetCacheTTLOverrideTarget() string { // GetQuotaLimit 获取 API Key 账号的配额限制(美元) // 返回 0 表示未启用 func (a *Account) GetQuotaLimit() float64 { - if a.Extra == nil { - return 0 - } - if v, ok := a.Extra["quota_limit"]; ok { - return parseExtraFloat64(v) - } - return 0 + return a.getExtraFloat64("quota_limit") } // GetQuotaUsed 获取 API Key 账号的已用配额(美元) func (a *Account) GetQuotaUsed() float64 { + return a.getExtraFloat64("quota_used") +} + +// GetQuotaDailyLimit 获取日额度限制(美元),0 表示未启用 +func (a *Account) GetQuotaDailyLimit() float64 { + return a.getExtraFloat64("quota_daily_limit") +} + +// GetQuotaDailyUsed 获取当日已用额度(美元) +func (a *Account) GetQuotaDailyUsed() float64 { + return a.getExtraFloat64("quota_daily_used") +} + +// GetQuotaWeeklyLimit 获取周额度限制(美元),0 表示未启用 +func (a *Account) GetQuotaWeeklyLimit() float64 { + return a.getExtraFloat64("quota_weekly_limit") +} + +// GetQuotaWeeklyUsed 获取本周已用额度(美元) +func (a *Account) GetQuotaWeeklyUsed() float64 { + return a.getExtraFloat64("quota_weekly_used") +} + +// getExtraFloat64 从 Extra 中读取指定 key 的 float64 值 +func (a *Account) getExtraFloat64(key string) float64 { if a.Extra == nil { return 0 } - if v, ok := a.Extra["quota_used"]; ok { + if v, ok := a.Extra[key]; ok { return parseExtraFloat64(v) } return 0 } -// IsQuotaExceeded 检查 API Key 账号配额是否已超限 -func (a *Account) IsQuotaExceeded() bool { - limit := a.GetQuotaLimit() - if limit <= 0 { - return false +// getExtraTime 从 Extra 中读取 RFC3339 时间戳 +func (a *Account) getExtraTime(key string) time.Time { + if a.Extra == nil { + return time.Time{} } - return a.GetQuotaUsed() >= limit + if v, ok := a.Extra[key]; ok { + if s, ok := v.(string); ok { + if t, err := time.Parse(time.RFC3339Nano, s); err == nil { + return t + } + if t, err := time.Parse(time.RFC3339, s); err == nil { + return t + } + } + } + return time.Time{} +} + +// HasAnyQuotaLimit 检查是否配置了任一维度的配额限制 +func (a *Account) HasAnyQuotaLimit() bool { + return a.GetQuotaLimit() > 0 || a.GetQuotaDailyLimit() > 0 || a.GetQuotaWeeklyLimit() > 0 +} + +// isPeriodExpired 检查指定周期(自 periodStart 起经过 dur)是否已过期 +func isPeriodExpired(periodStart time.Time, dur time.Duration) bool { + if periodStart.IsZero() { + return true // 从未使用过,视为过期(下次 increment 会初始化) + } + return time.Since(periodStart) >= dur +} + +// IsQuotaExceeded 检查 API Key 账号配额是否已超限(任一维度超限即返回 true) +func (a *Account) IsQuotaExceeded() bool { + // 总额度 + if limit := a.GetQuotaLimit(); limit > 0 && a.GetQuotaUsed() >= limit { + return true + } + // 日额度(周期过期视为未超限,下次 increment 会重置) + if limit := a.GetQuotaDailyLimit(); limit > 0 { + start := a.getExtraTime("quota_daily_start") + if !isPeriodExpired(start, 24*time.Hour) && a.GetQuotaDailyUsed() >= limit { + return true + } + } + // 周额度 + if limit := a.GetQuotaWeeklyLimit(); limit > 0 { + start := a.getExtraTime("quota_weekly_start") + if !isPeriodExpired(start, 7*24*time.Hour) && a.GetQuotaWeeklyUsed() >= limit { + return true + } + } + return false } // GetWindowCostLimit 获取 5h 窗口费用阈值(美元) diff --git a/backend/internal/service/account_service.go b/backend/internal/service/account_service.go index 26c0b1c2..a06d8048 100644 --- a/backend/internal/service/account_service.go +++ b/backend/internal/service/account_service.go @@ -68,9 +68,9 @@ type AccountRepository interface { UpdateSessionWindow(ctx context.Context, id int64, start, end *time.Time, status string) error UpdateExtra(ctx context.Context, id int64, updates map[string]any) error BulkUpdate(ctx context.Context, ids []int64, updates AccountBulkUpdate) (int64, error) - // IncrementQuotaUsed 原子递增 API Key 账号的配额用量 + // IncrementQuotaUsed 原子递增 API Key 账号的配额用量(总/日/周) IncrementQuotaUsed(ctx context.Context, id int64, amount float64) error - // ResetQuotaUsed 重置 API Key 账号的配额用量为 0 + // ResetQuotaUsed 重置 API Key 账号所有维度的配额用量为 0 ResetQuotaUsed(ctx context.Context, id int64) error } diff --git a/backend/internal/service/admin_service.go b/backend/internal/service/admin_service.go index 680268e0..a3ed4233 100644 --- a/backend/internal/service/admin_service.go +++ b/backend/internal/service/admin_service.go @@ -1484,9 +1484,11 @@ func (s *adminServiceImpl) UpdateAccount(ctx context.Context, id int64, input *U account.Credentials = input.Credentials } if len(input.Extra) > 0 { - // 保留 quota_used,防止编辑账号时意外重置配额用量 - if oldQuotaUsed, ok := account.Extra["quota_used"]; ok { - input.Extra["quota_used"] = oldQuotaUsed + // 保留配额用量字段,防止编辑账号时意外重置 + for _, key := range []string{"quota_used", "quota_daily_used", "quota_daily_start", "quota_weekly_used", "quota_weekly_start"} { + if v, ok := account.Extra[key]; ok { + input.Extra[key] = v + } } account.Extra = input.Extra } diff --git a/backend/internal/service/gateway_service.go b/backend/internal/service/gateway_service.go index 3a6003fc..83e4c8ee 100644 --- a/backend/internal/service/gateway_service.go +++ b/backend/internal/service/gateway_service.go @@ -6437,7 +6437,7 @@ func postUsageBilling(ctx context.Context, p *postUsageBillingParams, deps *bill } // 4. 账号配额用量(账号口径:TotalCost × 账号计费倍率) - if cost.TotalCost > 0 && p.Account.Type == AccountTypeAPIKey && p.Account.GetQuotaLimit() > 0 { + if cost.TotalCost > 0 && p.Account.Type == AccountTypeAPIKey && p.Account.HasAnyQuotaLimit() { accountCost := cost.TotalCost * p.AccountRateMultiplier if err := deps.accountRepo.IncrementQuotaUsed(ctx, p.Account.ID, accountCost); err != nil { slog.Error("increment account quota used failed", "account_id", p.Account.ID, "cost", accountCost, "error", err) diff --git a/frontend/src/components/account/AccountCapacityCell.vue b/frontend/src/components/account/AccountCapacityCell.vue index 2001b185..301277ec 100644 --- a/frontend/src/components/account/AccountCapacityCell.vue +++ b/frontend/src/components/account/AccountCapacityCell.vue @@ -73,21 +73,10 @@ -
- - - - - ${{ formatCost(currentQuotaUsed) }} - / - ${{ formatCost(account.quota_limit) }} - +
+ + +
@@ -96,6 +85,7 @@ import { computed } from 'vue' import { useI18n } from 'vue-i18n' import type { Account } from '@/types' +import QuotaBadge from './QuotaBadge.vue' const props = defineProps<{ account: Account @@ -304,46 +294,17 @@ const rpmTooltip = computed(() => { } }) -// 是否显示配额限制(仅 apikey 类型且设置了 quota_limit) -const showQuotaLimit = computed(() => { - return ( - props.account.type === 'apikey' && - props.account.quota_limit !== undefined && - props.account.quota_limit !== null && - props.account.quota_limit > 0 - ) +// 是否显示各维度配额(仅 apikey 类型) +const showDailyQuota = computed(() => { + return props.account.type === 'apikey' && (props.account.quota_daily_limit ?? 0) > 0 }) -// 当前已用配额 -const currentQuotaUsed = computed(() => props.account.quota_used ?? 0) - -// 配额状态样式 -const quotaClass = computed(() => { - if (!showQuotaLimit.value) return '' - - const used = currentQuotaUsed.value - const limit = props.account.quota_limit || 0 - - if (used >= limit) { - return 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400' - } - if (used >= limit * 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' +const showWeeklyQuota = computed(() => { + return props.account.type === 'apikey' && (props.account.quota_weekly_limit ?? 0) > 0 }) -// 配额提示文字 -const quotaTooltip = computed(() => { - if (!showQuotaLimit.value) return '' - - const used = currentQuotaUsed.value - const limit = props.account.quota_limit || 0 - - if (used >= limit) { - return t('admin.accounts.capacity.quota.exceeded') - } - return t('admin.accounts.capacity.quota.normal') +const showTotalQuota = computed(() => { + return props.account.type === 'apikey' && (props.account.quota_limit ?? 0) > 0 }) // 格式化费用显示 diff --git a/frontend/src/components/account/CreateAccountModal.vue b/frontend/src/components/account/CreateAccountModal.vue index 14064078..835ec853 100644 --- a/frontend/src/components/account/CreateAccountModal.vue +++ b/frontend/src/components/account/CreateAccountModal.vue @@ -1228,7 +1228,22 @@ - +
+
+

{{ t('admin.accounts.quotaLimit') }}

+

+ {{ t('admin.accounts.quotaLimitHint') }} +

+
+ +
('oauth') // For oauth-based: 'oauth' or 'setup- const apiKeyBaseUrl = ref('https://api.anthropic.com') const apiKeyValue = ref('') const editQuotaLimit = ref(null) +const editQuotaDailyLimit = ref(null) +const editQuotaWeeklyLimit = ref(null) const modelMappings = ref([]) const modelRestrictionMode = ref<'whitelist' | 'mapping'>('whitelist') const allowedModels = ref([]) @@ -3272,6 +3289,8 @@ const resetForm = () => { apiKeyBaseUrl.value = 'https://api.anthropic.com' apiKeyValue.value = '' editQuotaLimit.value = null + editQuotaDailyLimit.value = null + editQuotaWeeklyLimit.value = null modelMappings.value = [] modelRestrictionMode.value = 'whitelist' allowedModels.value = [...claudeModels] // Default fill related models @@ -3686,10 +3705,22 @@ const createAccountAndFinish = async ( if (!applyTempUnschedConfig(credentials)) { return } - // Inject quota_limit for apikey accounts + // Inject quota limits for apikey accounts let finalExtra = extra - if (type === 'apikey' && editQuotaLimit.value != null && editQuotaLimit.value > 0) { - finalExtra = { ...(extra || {}), quota_limit: editQuotaLimit.value } + if (type === 'apikey') { + const quotaExtra: Record = { ...(extra || {}) } + if (editQuotaLimit.value != null && editQuotaLimit.value > 0) { + quotaExtra.quota_limit = editQuotaLimit.value + } + if (editQuotaDailyLimit.value != null && editQuotaDailyLimit.value > 0) { + quotaExtra.quota_daily_limit = editQuotaDailyLimit.value + } + if (editQuotaWeeklyLimit.value != null && editQuotaWeeklyLimit.value > 0) { + quotaExtra.quota_weekly_limit = editQuotaWeeklyLimit.value + } + if (Object.keys(quotaExtra).length > 0) { + finalExtra = quotaExtra + } } await doCreateAccount({ name: form.name, diff --git a/frontend/src/components/account/EditAccountModal.vue b/frontend/src/components/account/EditAccountModal.vue index 148f95b6..200f3c3c 100644 --- a/frontend/src/components/account/EditAccountModal.vue +++ b/frontend/src/components/account/EditAccountModal.vue @@ -904,7 +904,22 @@
- +
+
+

{{ t('admin.accounts.quotaLimit') }}

+

+ {{ t('admin.accounts.quotaLimitHint') }} +

+
+ +
(OPENAI_WS_MODE_OF const codexCLIOnlyEnabled = ref(false) const anthropicPassthroughEnabled = ref(false) const editQuotaLimit = ref(null) +const editQuotaDailyLimit = ref(null) +const editQuotaWeeklyLimit = ref(null) const openAIWSModeOptions = computed(() => [ { value: OPENAI_WS_MODE_OFF, label: t('admin.accounts.openai.wsModeOff') }, // TODO: ctx_pool 选项暂时隐藏,待测试完成后恢复 @@ -1704,8 +1721,14 @@ watch( if (newAccount.type === 'apikey') { const quotaVal = extra?.quota_limit as number | undefined editQuotaLimit.value = (quotaVal && quotaVal > 0) ? quotaVal : null + const dailyVal = extra?.quota_daily_limit as number | undefined + editQuotaDailyLimit.value = (dailyVal && dailyVal > 0) ? dailyVal : null + const weeklyVal = extra?.quota_weekly_limit as number | undefined + editQuotaWeeklyLimit.value = (weeklyVal && weeklyVal > 0) ? weeklyVal : null } else { editQuotaLimit.value = null + editQuotaDailyLimit.value = null + editQuotaWeeklyLimit.value = null } // Load antigravity model mapping (Antigravity 只支持映射模式) @@ -2525,6 +2548,16 @@ const handleSubmit = async () => { } else { delete newExtra.quota_limit } + if (editQuotaDailyLimit.value != null && editQuotaDailyLimit.value > 0) { + newExtra.quota_daily_limit = editQuotaDailyLimit.value + } else { + delete newExtra.quota_daily_limit + } + if (editQuotaWeeklyLimit.value != null && editQuotaWeeklyLimit.value > 0) { + newExtra.quota_weekly_limit = editQuotaWeeklyLimit.value + } else { + delete newExtra.quota_weekly_limit + } updatePayload.extra = newExtra } diff --git a/frontend/src/components/account/QuotaBadge.vue b/frontend/src/components/account/QuotaBadge.vue new file mode 100644 index 00000000..7cf0f59d --- /dev/null +++ b/frontend/src/components/account/QuotaBadge.vue @@ -0,0 +1,49 @@ + + + diff --git a/frontend/src/components/account/QuotaLimitCard.vue b/frontend/src/components/account/QuotaLimitCard.vue index 1be73a25..505118ba 100644 --- a/frontend/src/components/account/QuotaLimitCard.vue +++ b/frontend/src/components/account/QuotaLimitCard.vue @@ -1,50 +1,59 @@ diff --git a/frontend/src/components/admin/account/AccountActionMenu.vue b/frontend/src/components/admin/account/AccountActionMenu.vue index 02596b9f..683a2092 100644 --- a/frontend/src/components/admin/account/AccountActionMenu.vue +++ b/frontend/src/components/admin/account/AccountActionMenu.vue @@ -76,10 +76,11 @@ const isRateLimited = computed(() => { }) const isOverloaded = computed(() => props.account?.overload_until && new Date(props.account.overload_until) > new Date()) const hasQuotaLimit = computed(() => { - return props.account?.type === 'apikey' && - props.account?.quota_limit !== undefined && - props.account?.quota_limit !== null && - props.account?.quota_limit > 0 + return props.account?.type === 'apikey' && ( + (props.account?.quota_limit ?? 0) > 0 || + (props.account?.quota_daily_limit ?? 0) > 0 || + (props.account?.quota_weekly_limit ?? 0) > 0 + ) }) const handleKeydown = (event: KeyboardEvent) => { diff --git a/frontend/src/i18n/locales/en.ts b/frontend/src/i18n/locales/en.ts index 1efff120..36dc790c 100644 --- a/frontend/src/i18n/locales/en.ts +++ b/frontend/src/i18n/locales/en.ts @@ -1794,11 +1794,17 @@ export default { resetQuota: 'Reset Quota', quotaLimit: 'Quota Limit', quotaLimitPlaceholder: '0 means unlimited', - quotaLimitHint: 'Set max spending limit (USD). Account will be paused when reached. Changing limit won\'t reset usage.', + quotaLimitHint: 'Set daily/weekly/total spending limits (USD). Account will be paused when any limit is reached. Changing limits won\'t reset usage.', quotaLimitToggle: 'Enable Quota Limit', quotaLimitToggleHint: 'When enabled, account will be paused when usage reaches the set limit', - quotaLimitAmount: 'Limit Amount', - quotaLimitAmountHint: 'Maximum spending limit (USD). Account will be auto-paused when reached. Changing limit won\'t reset usage.', + quotaDailyLimit: 'Daily Limit', + quotaDailyLimitHint: 'Automatically resets every 24 hours from first usage.', + quotaWeeklyLimit: 'Weekly Limit', + quotaWeeklyLimitHint: 'Automatically resets every 7 days from first usage.', + quotaTotalLimit: 'Total Limit', + quotaTotalLimitHint: 'Cumulative spending limit. Does not auto-reset — use "Reset Quota" to clear.', + quotaLimitAmount: 'Total Limit', + quotaLimitAmountHint: 'Cumulative spending limit. Does not auto-reset.', testConnection: 'Test Connection', reAuthorize: 'Re-Authorize', refreshToken: 'Refresh Token', diff --git a/frontend/src/i18n/locales/zh.ts b/frontend/src/i18n/locales/zh.ts index b2c38928..017b2cea 100644 --- a/frontend/src/i18n/locales/zh.ts +++ b/frontend/src/i18n/locales/zh.ts @@ -1801,11 +1801,17 @@ export default { resetQuota: '重置配额', quotaLimit: '配额限制', quotaLimitPlaceholder: '0 表示不限制', - quotaLimitHint: '设置最大使用额度(美元),达到后账号暂停调度。修改限额不会重置已用额度。', + quotaLimitHint: '设置日/周/总使用额度(美元),任一维度达到限额后账号暂停调度。修改限额不会重置已用额度。', quotaLimitToggle: '启用配额限制', quotaLimitToggleHint: '开启后,当账号用量达到设定额度时自动暂停调度', - quotaLimitAmount: '限额金额', - quotaLimitAmountHint: '账号最大可用额度(美元),达到后自动暂停。修改限额不会重置已用额度。', + quotaDailyLimit: '日限额', + quotaDailyLimitHint: '从首次使用起每 24 小时自动重置。', + quotaWeeklyLimit: '周限额', + quotaWeeklyLimitHint: '从首次使用起每 7 天自动重置。', + quotaTotalLimit: '总限额', + quotaTotalLimitHint: '累计消费上限,不会自动重置 — 使用「重置配额」手动清零。', + quotaLimitAmount: '总限额', + quotaLimitAmountHint: '累计消费上限,不会自动重置。', testConnection: '测试连接', reAuthorize: '重新授权', refreshToken: '刷新令牌', diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index 2d8a2487..46665742 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -719,6 +719,10 @@ export interface Account { // API Key 账号配额限制 quota_limit?: number | null quota_used?: number | null + quota_daily_limit?: number | null + quota_daily_used?: number | null + quota_weekly_limit?: number | null + quota_weekly_used?: number | null // 运行时状态(仅当启用对应限制时返回) current_window_cost?: number | null // 当前窗口费用 From d22e62ac8a451de016519df52072da469460963c Mon Sep 17 00:00:00 2001 From: erio Date: Sat, 7 Mar 2026 19:28:22 +0800 Subject: [PATCH 2/4] fix(test): add allow_messages_dispatch to group API contract test The recent upstream commit added allow_messages_dispatch to the Group DTO but did not update the API contract test expectation. Co-Authored-By: Claude Opus 4.6 --- backend/internal/server/api_contract_test.go | 1 + 1 file changed, 1 insertion(+) diff --git a/backend/internal/server/api_contract_test.go b/backend/internal/server/api_contract_test.go index aafbbe21..32126791 100644 --- a/backend/internal/server/api_contract_test.go +++ b/backend/internal/server/api_contract_test.go @@ -212,6 +212,7 @@ func TestAPIContracts(t *testing.T) { "claude_code_only": false, "fallback_group_id": null, "fallback_group_id_on_invalid_request": null, + "allow_messages_dispatch": false, "created_at": "2025-01-02T03:04:05Z", "updated_at": "2025-01-02T03:04:05Z" } From be75fc3474cc98c883345458d2e88280b63e8b91 Mon Sep 17 00:00:00 2001 From: erio Date: Sun, 8 Mar 2026 01:42:18 +0800 Subject: [PATCH 3/4] ci: retrigger CI checks From 2475d4a20596b047315f30e0a59176149bf0f7d0 Mon Sep 17 00:00:00 2001 From: erio Date: Sun, 8 Mar 2026 01:45:22 +0800 Subject: [PATCH 4/4] feat: add marquee selection box overlay during drag-to-select Show a semi-transparent blue rectangle overlay while dragging to select rows, matching the project's primary color theme with dark mode support. The box spans the full table width from drag start to current mouse position. Co-Authored-By: Claude Opus 4.6 --- frontend/src/components/common/DataTable.vue | 1 + frontend/src/composables/useSwipeSelect.ts | 224 +++++++++++++++++++ frontend/src/views/admin/AccountsView.vue | 9 + frontend/src/views/admin/ProxiesView.vue | 9 + 4 files changed, 243 insertions(+) create mode 100644 frontend/src/composables/useSwipeSelect.ts diff --git a/frontend/src/components/common/DataTable.vue b/frontend/src/components/common/DataTable.vue index 43755301..16aea107 100644 --- a/frontend/src/components/common/DataTable.vue +++ b/frontend/src/components/common/DataTable.vue @@ -152,6 +152,7 @@ v-else v-for="(row, index) in sortedData" :key="resolveRowKey(row, index)" + :data-row-id="resolveRowKey(row, index)" class="hover:bg-gray-50 dark:hover:bg-dark-800" > boolean + select: (id: number) => void + deselect: (id: number) => void +} + +export function useSwipeSelect( + containerRef: Ref, + adapter: SwipeSelectAdapter +) { + const isDragging = ref(false) + + let dragMode: 'select' | 'deselect' = 'select' + let startRowIndex = -1 + let lastEndIndex = -1 + let startY = 0 + let initialSelectedSnapshot = new Map() + let cachedRows: HTMLElement[] = [] + let marqueeEl: HTMLDivElement | null = null + + function getDataRows(): HTMLElement[] { + const container = containerRef.value + if (!container) return [] + return Array.from(container.querySelectorAll('tbody tr[data-row-id]')) + } + + function getRowId(el: HTMLElement): number | null { + const raw = el.getAttribute('data-row-id') + if (raw === null) return null + const id = Number(raw) + return Number.isFinite(id) ? id : null + } + + // --- Marquee overlay --- + function createMarquee() { + marqueeEl = document.createElement('div') + const isDark = document.documentElement.classList.contains('dark') + Object.assign(marqueeEl.style, { + position: 'fixed', + background: isDark ? 'rgba(96, 165, 250, 0.15)' : 'rgba(59, 130, 246, 0.12)', + border: isDark ? '1.5px solid rgba(96, 165, 250, 0.5)' : '1.5px solid rgba(59, 130, 246, 0.4)', + borderRadius: '4px', + pointerEvents: 'none', + zIndex: '9999', + transition: 'none' + }) + document.body.appendChild(marqueeEl) + } + + function updateMarquee(currentY: number) { + if (!marqueeEl || !containerRef.value) return + const containerRect = containerRef.value.getBoundingClientRect() + + const top = Math.min(startY, currentY) + const bottom = Math.max(startY, currentY) + + // Clamp to container horizontal bounds, extend full width + marqueeEl.style.left = containerRect.left + 'px' + marqueeEl.style.width = containerRect.width + 'px' + marqueeEl.style.top = top + 'px' + marqueeEl.style.height = (bottom - top) + 'px' + } + + function removeMarquee() { + if (marqueeEl) { + marqueeEl.remove() + marqueeEl = null + } + } + + // --- Row selection logic --- + function applyRange(endIndex: number) { + const rangeMin = Math.min(startRowIndex, endIndex) + const rangeMax = Math.max(startRowIndex, endIndex) + const prevMin = lastEndIndex >= 0 ? Math.min(startRowIndex, lastEndIndex) : rangeMin + const prevMax = lastEndIndex >= 0 ? Math.max(startRowIndex, lastEndIndex) : rangeMax + + const lo = Math.min(rangeMin, prevMin) + const hi = Math.max(rangeMax, prevMax) + + for (let i = lo; i <= hi && i < cachedRows.length; i++) { + const id = getRowId(cachedRows[i]) + if (id === null) continue + + if (i >= rangeMin && i <= rangeMax) { + if (dragMode === 'select') { + adapter.select(id) + } else { + adapter.deselect(id) + } + } else { + const wasSelected = initialSelectedSnapshot.get(id) ?? false + if (wasSelected) { + adapter.select(id) + } else { + adapter.deselect(id) + } + } + } + + lastEndIndex = endIndex + } + + function onMouseDown(e: MouseEvent) { + if (e.button !== 0) return + + const target = e.target as HTMLElement + if (target.closest('button, a, input, select, textarea, [role="button"], [role="menuitem"]')) return + if (!target.closest('tbody')) return + + cachedRows = getDataRows() + const tr = target.closest('tr[data-row-id]') as HTMLElement | null + if (!tr) return + const rowIndex = cachedRows.indexOf(tr) + if (rowIndex < 0) return + + const rowId = getRowId(tr) + if (rowId === null) return + + initialSelectedSnapshot = new Map() + for (const row of cachedRows) { + const id = getRowId(row) + if (id !== null) { + initialSelectedSnapshot.set(id, adapter.isSelected(id)) + } + } + + isDragging.value = true + startRowIndex = rowIndex + lastEndIndex = -1 + startY = e.clientY + dragMode = adapter.isSelected(rowId) ? 'deselect' : 'select' + + applyRange(rowIndex) + + // Create visual marquee + createMarquee() + updateMarquee(e.clientY) + + e.preventDefault() + document.body.style.userSelect = 'none' + document.addEventListener('mousemove', onMouseMove) + document.addEventListener('mouseup', onMouseUp) + } + + function onMouseMove(e: MouseEvent) { + if (!isDragging.value) return + + // Update marquee box + updateMarquee(e.clientY) + + const el = document.elementFromPoint(e.clientX, e.clientY) as HTMLElement | null + if (!el) return + + const tr = el.closest('tr[data-row-id]') as HTMLElement | null + if (!tr) return + const rowIndex = cachedRows.indexOf(tr) + if (rowIndex < 0) return + + applyRange(rowIndex) + autoScroll(e) + } + + function onMouseUp() { + isDragging.value = false + startRowIndex = -1 + lastEndIndex = -1 + cachedRows = [] + initialSelectedSnapshot.clear() + stopAutoScroll() + removeMarquee() + document.body.style.userSelect = '' + + document.removeEventListener('mousemove', onMouseMove) + document.removeEventListener('mouseup', onMouseUp) + } + + // --- Auto-scroll --- + let scrollRAF = 0 + const SCROLL_ZONE = 40 + const SCROLL_SPEED = 8 + + function autoScroll(e: MouseEvent) { + cancelAnimationFrame(scrollRAF) + const container = containerRef.value + if (!container) return + + const rect = container.getBoundingClientRect() + let dy = 0 + if (e.clientY < rect.top + SCROLL_ZONE) { + dy = -SCROLL_SPEED + } else if (e.clientY > rect.bottom - SCROLL_ZONE) { + dy = SCROLL_SPEED + } + + if (dy !== 0) { + const step = () => { + container.scrollTop += dy + scrollRAF = requestAnimationFrame(step) + } + scrollRAF = requestAnimationFrame(step) + } + } + + function stopAutoScroll() { + cancelAnimationFrame(scrollRAF) + } + + onMounted(() => { + containerRef.value?.addEventListener('mousedown', onMouseDown) + }) + + onUnmounted(() => { + containerRef.value?.removeEventListener('mousedown', onMouseDown) + document.removeEventListener('mousemove', onMouseMove) + document.removeEventListener('mouseup', onMouseUp) + stopAutoScroll() + removeMarquee() + }) + + return { isDragging } +} diff --git a/frontend/src/views/admin/AccountsView.vue b/frontend/src/views/admin/AccountsView.vue index 0173ea0a..b608aa97 100644 --- a/frontend/src/views/admin/AccountsView.vue +++ b/frontend/src/views/admin/AccountsView.vue @@ -132,6 +132,7 @@ +
@@ -285,6 +287,7 @@ import { useAppStore } from '@/stores/app' import { useAuthStore } from '@/stores/auth' import { adminAPI } from '@/api/admin' import { useTableLoader } from '@/composables/useTableLoader' +import { useSwipeSelect } from '@/composables/useSwipeSelect' import AppLayout from '@/components/layout/AppLayout.vue' import TablePageLayout from '@/components/layout/TablePageLayout.vue' import DataTable from '@/components/common/DataTable.vue' @@ -319,6 +322,12 @@ const authStore = useAuthStore() const proxies = ref([]) const groups = ref([]) const selIds = ref([]) +const accountTableRef = ref(null) +useSwipeSelect(accountTableRef, { + isSelected: (id) => selIds.value.includes(id), + select: (id) => { if (!selIds.value.includes(id)) selIds.value.push(id) }, + deselect: (id) => { selIds.value = selIds.value.filter(x => x !== id) } +}) const selPlatforms = computed(() => { const platforms = new Set( accounts.value diff --git a/frontend/src/views/admin/ProxiesView.vue b/frontend/src/views/admin/ProxiesView.vue index 147b3205..c26aa233 100644 --- a/frontend/src/views/admin/ProxiesView.vue +++ b/frontend/src/views/admin/ProxiesView.vue @@ -88,6 +88,7 @@