mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-03-30 00:31:24 +00:00
Merge pull request #841 from touwaeriol/feature/account-periodic-quota
feat(account): 为 API Key 账号新增日/周周期性配额限制
This commit is contained in:
@@ -73,21 +73,10 @@
|
||||
</div>
|
||||
|
||||
<!-- API Key 账号配额限制 -->
|
||||
<div v-if="showQuotaLimit" class="flex items-center gap-1">
|
||||
<span
|
||||
:class="[
|
||||
'inline-flex items-center gap-1 rounded-md px-1.5 py-0.5 text-[10px] font-medium',
|
||||
quotaClass
|
||||
]"
|
||||
:title="quotaTooltip"
|
||||
>
|
||||
<svg class="h-2.5 w-2.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M2.25 18.75a60.07 60.07 0 0115.797 2.101c.727.198 1.453-.342 1.453-1.096V18.75M3.75 4.5v.75A.75.75 0 013 6h-.75m0 0v-.375c0-.621.504-1.125 1.125-1.125H20.25M2.25 6v9m18-10.5v.75c0 .414.336.75.75.75h.75m-1.5-1.5h.375c.621 0 1.125.504 1.125 1.125v9.75c0 .621-.504 1.125-1.125 1.125h-.375m1.5-1.5H21a.75.75 0 00-.75.75v.75m0 0H3.75m0 0h-.375a1.125 1.125 0 01-1.125-1.125V15m1.5 1.5v-.75A.75.75 0 003 15h-.75M15 10.5a3 3 0 11-6 0 3 3 0 016 0zm3 0h.008v.008H18V10.5zm-12 0h.008v.008H6V10.5z" />
|
||||
</svg>
|
||||
<span class="font-mono">${{ formatCost(currentQuotaUsed) }}</span>
|
||||
<span class="text-gray-400 dark:text-gray-500">/</span>
|
||||
<span class="font-mono">${{ formatCost(account.quota_limit) }}</span>
|
||||
</span>
|
||||
<div v-if="showDailyQuota || showWeeklyQuota || showTotalQuota" class="flex items-center gap-1">
|
||||
<QuotaBadge v-if="showDailyQuota" :used="account.quota_daily_used ?? 0" :limit="account.quota_daily_limit!" label="D" />
|
||||
<QuotaBadge v-if="showWeeklyQuota" :used="account.quota_weekly_used ?? 0" :limit="account.quota_weekly_limit!" label="W" />
|
||||
<QuotaBadge v-if="showTotalQuota" :used="account.quota_used ?? 0" :limit="account.quota_limit!" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -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
|
||||
})
|
||||
|
||||
// 格式化费用显示
|
||||
|
||||
@@ -1228,7 +1228,22 @@
|
||||
</div>
|
||||
|
||||
<!-- API Key 账号配额限制 -->
|
||||
<QuotaLimitCard v-if="form.type === 'apikey'" v-model="editQuotaLimit" />
|
||||
<div v-if="form.type === 'apikey'" class="border-t border-gray-200 pt-4 dark:border-dark-600 space-y-4">
|
||||
<div class="mb-3">
|
||||
<h3 class="input-label mb-0 text-base font-semibold">{{ t('admin.accounts.quotaLimit') }}</h3>
|
||||
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ t('admin.accounts.quotaLimitHint') }}
|
||||
</p>
|
||||
</div>
|
||||
<QuotaLimitCard
|
||||
:totalLimit="editQuotaLimit"
|
||||
:dailyLimit="editQuotaDailyLimit"
|
||||
:weeklyLimit="editQuotaWeeklyLimit"
|
||||
@update:totalLimit="editQuotaLimit = $event"
|
||||
@update:dailyLimit="editQuotaDailyLimit = $event"
|
||||
@update:weeklyLimit="editQuotaWeeklyLimit = $event"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- OpenAI OAuth Model Mapping (OAuth 类型没有 apikey 容器,需要独立的模型映射区域) -->
|
||||
<div
|
||||
@@ -2609,6 +2624,8 @@ const addMethod = ref<AddMethod>('oauth') // For oauth-based: 'oauth' or 'setup-
|
||||
const apiKeyBaseUrl = ref('https://api.anthropic.com')
|
||||
const apiKeyValue = ref('')
|
||||
const editQuotaLimit = ref<number | null>(null)
|
||||
const editQuotaDailyLimit = ref<number | null>(null)
|
||||
const editQuotaWeeklyLimit = ref<number | null>(null)
|
||||
const modelMappings = ref<ModelMapping[]>([])
|
||||
const modelRestrictionMode = ref<'whitelist' | 'mapping'>('whitelist')
|
||||
const allowedModels = ref<string[]>([])
|
||||
@@ -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<string, unknown> = { ...(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,
|
||||
|
||||
@@ -904,7 +904,22 @@
|
||||
</div>
|
||||
|
||||
<!-- API Key 账号配额限制 -->
|
||||
<QuotaLimitCard v-if="account?.type === 'apikey'" v-model="editQuotaLimit" />
|
||||
<div v-if="account?.type === 'apikey'" class="border-t border-gray-200 pt-4 dark:border-dark-600 space-y-4">
|
||||
<div class="mb-3">
|
||||
<h3 class="input-label mb-0 text-base font-semibold">{{ t('admin.accounts.quotaLimit') }}</h3>
|
||||
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ t('admin.accounts.quotaLimitHint') }}
|
||||
</p>
|
||||
</div>
|
||||
<QuotaLimitCard
|
||||
:totalLimit="editQuotaLimit"
|
||||
:dailyLimit="editQuotaDailyLimit"
|
||||
:weeklyLimit="editQuotaWeeklyLimit"
|
||||
@update:totalLimit="editQuotaLimit = $event"
|
||||
@update:dailyLimit="editQuotaDailyLimit = $event"
|
||||
@update:weeklyLimit="editQuotaWeeklyLimit = $event"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- OpenAI OAuth Codex 官方客户端限制开关 -->
|
||||
<div
|
||||
@@ -1535,6 +1550,8 @@ const openaiAPIKeyResponsesWebSocketV2Mode = ref<OpenAIWSMode>(OPENAI_WS_MODE_OF
|
||||
const codexCLIOnlyEnabled = ref(false)
|
||||
const anthropicPassthroughEnabled = ref(false)
|
||||
const editQuotaLimit = ref<number | null>(null)
|
||||
const editQuotaDailyLimit = ref<number | null>(null)
|
||||
const editQuotaWeeklyLimit = ref<number | null>(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
|
||||
}
|
||||
|
||||
|
||||
49
frontend/src/components/account/QuotaBadge.vue
Normal file
49
frontend/src/components/account/QuotaBadge.vue
Normal file
@@ -0,0 +1,49 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
const props = defineProps<{
|
||||
used: number
|
||||
limit: number
|
||||
label?: string // 文字前缀,如 "D" / "W";不传时显示 icon
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const badgeClass = computed(() => {
|
||||
if (props.used >= props.limit) {
|
||||
return 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400'
|
||||
}
|
||||
if (props.used >= props.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 tooltip = computed(() => {
|
||||
if (props.used >= props.limit) {
|
||||
return t('admin.accounts.capacity.quota.exceeded')
|
||||
}
|
||||
return t('admin.accounts.capacity.quota.normal')
|
||||
})
|
||||
|
||||
const fmt = (v: number) => v.toFixed(2)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span
|
||||
:class="[
|
||||
'inline-flex items-center gap-1 rounded-md px-1.5 py-px text-[10px] font-medium leading-tight',
|
||||
badgeClass
|
||||
]"
|
||||
:title="tooltip"
|
||||
>
|
||||
<span v-if="label" class="font-semibold opacity-70">{{ label }}</span>
|
||||
<svg v-else class="h-2.5 w-2.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M2.25 18.75a60.07 60.07 0 0115.797 2.101c.727.198 1.453-.342 1.453-1.096V18.75M3.75 4.5v.75A.75.75 0 013 6h-.75m0 0v-.375c0-.621.504-1.125 1.125-1.125H20.25M2.25 6v9m18-10.5v.75c0 .414.336.75.75.75h.75m-1.5-1.5h.375c.621 0 1.125.504 1.125 1.125v9.75c0 .621-.504 1.125-1.125 1.125h-.375m1.5-1.5H21a.75.75 0 00-.75.75v.75m0 0H3.75m0 0h-.375a1.125 1.125 0 01-1.125-1.125V15m1.5 1.5v-.75A.75.75 0 003 15h-.75M15 10.5a3 3 0 11-6 0 3 3 0 016 0zm3 0h.008v.008H18V10.5zm-12 0h.008v.008H6V10.5z" />
|
||||
</svg>
|
||||
<span class="font-mono">${{ fmt(used) }}</span>
|
||||
<span class="text-gray-400 dark:text-gray-500">/</span>
|
||||
<span class="font-mono">${{ fmt(limit) }}</span>
|
||||
</span>
|
||||
</template>
|
||||
@@ -1,50 +1,59 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from 'vue'
|
||||
import { ref, watch, computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: number | null
|
||||
totalLimit: number | null
|
||||
dailyLimit: number | null
|
||||
weeklyLimit: number | null
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: number | null]
|
||||
'update:totalLimit': [value: number | null]
|
||||
'update:dailyLimit': [value: number | null]
|
||||
'update:weeklyLimit': [value: number | null]
|
||||
}>()
|
||||
|
||||
const enabled = ref(props.modelValue != null && props.modelValue > 0)
|
||||
|
||||
// Sync enabled state when modelValue changes externally (e.g. account load)
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(val) => {
|
||||
enabled.value = val != null && val > 0
|
||||
}
|
||||
const enabled = computed(() =>
|
||||
(props.totalLimit != null && props.totalLimit > 0) ||
|
||||
(props.dailyLimit != null && props.dailyLimit > 0) ||
|
||||
(props.weeklyLimit != null && props.weeklyLimit > 0)
|
||||
)
|
||||
|
||||
// When toggle is turned off, clear the value
|
||||
const localEnabled = ref(enabled.value)
|
||||
|
||||
// Sync when props change externally
|
||||
watch(enabled, (val) => {
|
||||
localEnabled.value = val
|
||||
})
|
||||
|
||||
// When toggle is turned off, clear all values
|
||||
watch(localEnabled, (val) => {
|
||||
if (!val) {
|
||||
emit('update:modelValue', null)
|
||||
emit('update:totalLimit', null)
|
||||
emit('update:dailyLimit', null)
|
||||
emit('update:weeklyLimit', null)
|
||||
}
|
||||
})
|
||||
|
||||
const onInput = (e: Event) => {
|
||||
const onTotalInput = (e: Event) => {
|
||||
const raw = (e.target as HTMLInputElement).valueAsNumber
|
||||
emit('update:modelValue', Number.isNaN(raw) ? null : raw)
|
||||
emit('update:totalLimit', Number.isNaN(raw) ? null : raw)
|
||||
}
|
||||
const onDailyInput = (e: Event) => {
|
||||
const raw = (e.target as HTMLInputElement).valueAsNumber
|
||||
emit('update:dailyLimit', Number.isNaN(raw) ? null : raw)
|
||||
}
|
||||
const onWeeklyInput = (e: Event) => {
|
||||
const raw = (e.target as HTMLInputElement).valueAsNumber
|
||||
emit('update:weeklyLimit', Number.isNaN(raw) ? null : raw)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="border-t border-gray-200 pt-4 dark:border-dark-600 space-y-4">
|
||||
<div class="mb-3">
|
||||
<h3 class="input-label mb-0 text-base font-semibold">{{ t('admin.accounts.quotaLimit') }}</h3>
|
||||
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ t('admin.accounts.quotaLimitHint') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg border border-gray-200 p-4 dark:border-dark-600">
|
||||
<div class="rounded-lg border border-gray-200 p-4 dark:border-dark-600">
|
||||
<div class="mb-3 flex items-center justify-between">
|
||||
<div>
|
||||
<label class="input-label mb-0">{{ t('admin.accounts.quotaLimitToggle') }}</label>
|
||||
@@ -54,29 +63,30 @@ const onInput = (e: Event) => {
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
@click="enabled = !enabled"
|
||||
@click="localEnabled = !localEnabled"
|
||||
:class="[
|
||||
'relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2',
|
||||
enabled ? 'bg-primary-600' : 'bg-gray-200 dark:bg-dark-600'
|
||||
localEnabled ? 'bg-primary-600' : 'bg-gray-200 dark:bg-dark-600'
|
||||
]"
|
||||
>
|
||||
<span
|
||||
:class="[
|
||||
'pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out',
|
||||
enabled ? 'translate-x-5' : 'translate-x-0'
|
||||
localEnabled ? 'translate-x-5' : 'translate-x-0'
|
||||
]"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="enabled" class="space-y-3">
|
||||
<div v-if="localEnabled" class="space-y-3">
|
||||
<!-- 日配额 -->
|
||||
<div>
|
||||
<label class="input-label">{{ t('admin.accounts.quotaLimitAmount') }}</label>
|
||||
<label class="input-label">{{ t('admin.accounts.quotaDailyLimit') }}</label>
|
||||
<div class="relative">
|
||||
<span class="absolute left-3 top-1/2 -translate-y-1/2 text-gray-500 dark:text-gray-400">$</span>
|
||||
<input
|
||||
:value="modelValue"
|
||||
@input="onInput"
|
||||
:value="dailyLimit"
|
||||
@input="onDailyInput"
|
||||
type="number"
|
||||
min="0"
|
||||
step="0.01"
|
||||
@@ -84,9 +94,44 @@ const onInput = (e: Event) => {
|
||||
:placeholder="t('admin.accounts.quotaLimitPlaceholder')"
|
||||
/>
|
||||
</div>
|
||||
<p class="input-hint">{{ t('admin.accounts.quotaLimitAmountHint') }}</p>
|
||||
<p class="input-hint">{{ t('admin.accounts.quotaDailyLimitHint') }}</p>
|
||||
</div>
|
||||
|
||||
<!-- 周配额 -->
|
||||
<div>
|
||||
<label class="input-label">{{ t('admin.accounts.quotaWeeklyLimit') }}</label>
|
||||
<div class="relative">
|
||||
<span class="absolute left-3 top-1/2 -translate-y-1/2 text-gray-500 dark:text-gray-400">$</span>
|
||||
<input
|
||||
:value="weeklyLimit"
|
||||
@input="onWeeklyInput"
|
||||
type="number"
|
||||
min="0"
|
||||
step="0.01"
|
||||
class="input pl-7"
|
||||
:placeholder="t('admin.accounts.quotaLimitPlaceholder')"
|
||||
/>
|
||||
</div>
|
||||
<p class="input-hint">{{ t('admin.accounts.quotaWeeklyLimitHint') }}</p>
|
||||
</div>
|
||||
|
||||
<!-- 总配额 -->
|
||||
<div>
|
||||
<label class="input-label">{{ t('admin.accounts.quotaTotalLimit') }}</label>
|
||||
<div class="relative">
|
||||
<span class="absolute left-3 top-1/2 -translate-y-1/2 text-gray-500 dark:text-gray-400">$</span>
|
||||
<input
|
||||
:value="totalLimit"
|
||||
@input="onTotalInput"
|
||||
type="number"
|
||||
min="0"
|
||||
step="0.01"
|
||||
class="input pl-7"
|
||||
:placeholder="t('admin.accounts.quotaLimitPlaceholder')"
|
||||
/>
|
||||
</div>
|
||||
<p class="input-hint">{{ t('admin.accounts.quotaTotalLimitHint') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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"
|
||||
>
|
||||
<td
|
||||
|
||||
224
frontend/src/composables/useSwipeSelect.ts
Normal file
224
frontend/src/composables/useSwipeSelect.ts
Normal file
@@ -0,0 +1,224 @@
|
||||
import { ref, onMounted, onUnmounted, type Ref } from 'vue'
|
||||
|
||||
export interface SwipeSelectAdapter {
|
||||
isSelected: (id: number) => boolean
|
||||
select: (id: number) => void
|
||||
deselect: (id: number) => void
|
||||
}
|
||||
|
||||
export function useSwipeSelect(
|
||||
containerRef: Ref<HTMLElement | null>,
|
||||
adapter: SwipeSelectAdapter
|
||||
) {
|
||||
const isDragging = ref(false)
|
||||
|
||||
let dragMode: 'select' | 'deselect' = 'select'
|
||||
let startRowIndex = -1
|
||||
let lastEndIndex = -1
|
||||
let startY = 0
|
||||
let initialSelectedSnapshot = new Map<number, boolean>()
|
||||
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 }
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -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: '刷新令牌',
|
||||
|
||||
@@ -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 // 当前窗口费用
|
||||
|
||||
@@ -132,6 +132,7 @@
|
||||
</template>
|
||||
<template #table>
|
||||
<AccountBulkActionsBar :selected-ids="selIds" @delete="handleBulkDelete" @edit="showBulkEdit = true" @clear="selIds = []" @select-page="selectPage" @toggle-schedulable="handleBulkToggleSchedulable" />
|
||||
<div ref="accountTableRef">
|
||||
<DataTable
|
||||
:columns="cols"
|
||||
:data="accounts"
|
||||
@@ -252,6 +253,7 @@
|
||||
</div>
|
||||
</template>
|
||||
</DataTable>
|
||||
</div>
|
||||
</template>
|
||||
<template #pagination><Pagination v-if="pagination.total > 0" :page="pagination.page" :total="pagination.total" :page-size="pagination.page_size" @update:page="handlePageChange" @update:pageSize="handlePageSizeChange" /></template>
|
||||
</TablePageLayout>
|
||||
@@ -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<Proxy[]>([])
|
||||
const groups = ref<AdminGroup[]>([])
|
||||
const selIds = ref<number[]>([])
|
||||
const accountTableRef = ref<HTMLElement | null>(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<AccountPlatform[]>(() => {
|
||||
const platforms = new Set(
|
||||
accounts.value
|
||||
|
||||
@@ -88,6 +88,7 @@
|
||||
</template>
|
||||
|
||||
<template #table>
|
||||
<div ref="proxyTableRef">
|
||||
<DataTable :columns="columns" :data="proxies" :loading="loading">
|
||||
<template #header-select>
|
||||
<input
|
||||
@@ -325,6 +326,7 @@
|
||||
/>
|
||||
</template>
|
||||
</DataTable>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #pagination>
|
||||
@@ -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<Set<number>>(new Set())
|
||||
const batchTesting = ref(false)
|
||||
const batchQualityChecking = ref(false)
|
||||
const selectedProxyIds = ref<Set<number>>(new Set())
|
||||
const proxyTableRef = ref<HTMLElement | null>(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<Proxy | null>(null)
|
||||
const proxyAccounts = ref<ProxyAccountSummary[]>([])
|
||||
const accountsLoading = ref(false)
|
||||
|
||||
Reference in New Issue
Block a user