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 @@
+
+
+
+
+ {{ label }}
+
+ ${{ fmt(used) }}
+ /
+ ${{ fmt(limit) }}
+
+
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 @@
-
-
-
{{ t('admin.accounts.quotaLimit') }}
-
- {{ t('admin.accounts.quotaLimitHint') }}
-
-
-
-
+
@@ -54,29 +63,30 @@ const onInput = (e: Event) => {
-
+
+
-
+
$
{
:placeholder="t('admin.accounts.quotaLimitPlaceholder')"
/>
-
{{ t('admin.accounts.quotaLimitAmountHint') }}
+
{{ t('admin.accounts.quotaDailyLimitHint') }}
+
+
+
+
+
+
+ $
+
+
+
{{ t('admin.accounts.quotaWeeklyLimitHint') }}
+
+
+
+
+
+
+ $
+
+
+
{{ t('admin.accounts.quotaTotalLimitHint') }}
-
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 @@
+
+
@@ -880,6 +882,7 @@ import Select from '@/components/common/Select.vue'
import Icon from '@/components/icons/Icon.vue'
import PlatformTypeBadge from '@/components/common/PlatformTypeBadge.vue'
import { useClipboard } from '@/composables/useClipboard'
+import { useSwipeSelect } from '@/composables/useSwipeSelect'
const { t } = useI18n()
const appStore = useAppStore()
@@ -959,6 +962,12 @@ const qualityCheckingProxyIds = ref>(new Set())
const batchTesting = ref(false)
const batchQualityChecking = ref(false)
const selectedProxyIds = ref>(new Set())
+const proxyTableRef = ref(null)
+useSwipeSelect(proxyTableRef, {
+ isSelected: (id) => selectedProxyIds.value.has(id),
+ select: (id) => { const next = new Set(selectedProxyIds.value); next.add(id); selectedProxyIds.value = next },
+ deselect: (id) => { const next = new Set(selectedProxyIds.value); next.delete(id); selectedProxyIds.value = next }
+})
const accountsProxy = ref(null)
const proxyAccounts = ref([])
const accountsLoading = ref(false)
|