diff --git a/backend/internal/handler/dto/mappers.go b/backend/internal/handler/dto/mappers.go index 0386189e..83bf9f38 100644 --- a/backend/internal/handler/dto/mappers.go +++ b/backend/internal/handler/dto/mappers.go @@ -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/server/api_contract_test.go b/backend/internal/server/api_contract_test.go index a0b4542b..236bd658 100644 --- a/backend/internal/server/api_contract_test.go +++ b/backend/internal/server/api_contract_test.go @@ -213,6 +213,7 @@ func TestAPIContracts(t *testing.T) { "allow_messages_dispatch": 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" } 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 924f2009..06b182cf 100644 --- a/backend/internal/service/gateway_service.go +++ b/backend/internal/service/gateway_service.go @@ -6478,7 +6478,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/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/i18n/locales/en.ts b/frontend/src/i18n/locales/en.ts index 270d68c5..744b7bc6 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 44fa5fbf..9313b2f9 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 // 当前窗口费用 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 @@