This feature allows API Keys to have their own quota limits and expiration times, independent of the user's balance. Backend: - Add quota, quota_used, expires_at fields to api_key schema - Implement IsExpired() and IsQuotaExhausted() checks in middleware - Add ResetQuota and ClearExpiration API endpoints - Integrate quota billing in gateway handlers (OpenAI, Anthropic, Gemini) - Include quota/expiration fields in auth cache for performance - Expiration check returns 403, quota exhausted returns 429 Frontend: - Add quota and expiration inputs to key create/edit dialog - Add quick-select buttons for expiration (+7, +30, +90 days) - Add reset quota confirmation dialog - Add expires_at column to keys list - Add i18n translations for new features (en/zh) Migration: - Add 045_add_api_key_quota.sql for new columns
1279 lines
46 KiB
Vue
1279 lines
46 KiB
Vue
<template>
|
|
<AppLayout>
|
|
<TablePageLayout>
|
|
<template #actions>
|
|
<div class="flex justify-end gap-3">
|
|
<button
|
|
@click="loadApiKeys"
|
|
:disabled="loading"
|
|
class="btn btn-secondary"
|
|
:title="t('common.refresh')"
|
|
>
|
|
<Icon name="refresh" size="md" :class="loading ? 'animate-spin' : ''" />
|
|
</button>
|
|
<button @click="showCreateModal = true" class="btn btn-primary" data-tour="keys-create-btn">
|
|
<Icon name="plus" size="md" class="mr-2" />
|
|
{{ t('keys.createKey') }}
|
|
</button>
|
|
</div>
|
|
</template>
|
|
|
|
<template #table>
|
|
<DataTable :columns="columns" :data="apiKeys" :loading="loading">
|
|
<template #cell-key="{ value, row }">
|
|
<div class="flex items-center gap-2">
|
|
<code class="code text-xs">
|
|
{{ maskKey(value) }}
|
|
</code>
|
|
<button
|
|
@click="copyToClipboard(value, row.id)"
|
|
class="rounded-lg p-1 transition-colors hover:bg-gray-100 dark:hover:bg-dark-700"
|
|
:class="
|
|
copiedKeyId === row.id
|
|
? 'text-green-500'
|
|
: 'text-gray-400 hover:text-gray-600 dark:hover:text-gray-300'
|
|
"
|
|
:title="copiedKeyId === row.id ? t('keys.copied') : t('keys.copyToClipboard')"
|
|
>
|
|
<Icon
|
|
v-if="copiedKeyId === row.id"
|
|
name="check"
|
|
size="sm"
|
|
:stroke-width="2"
|
|
/>
|
|
<Icon v-else name="clipboard" size="sm" />
|
|
</button>
|
|
</div>
|
|
</template>
|
|
|
|
<template #cell-name="{ value, row }">
|
|
<div class="flex items-center gap-1.5">
|
|
<span class="font-medium text-gray-900 dark:text-white">{{ value }}</span>
|
|
<Icon
|
|
v-if="row.ip_whitelist?.length > 0 || row.ip_blacklist?.length > 0"
|
|
name="shield"
|
|
size="sm"
|
|
class="text-blue-500"
|
|
:title="t('keys.ipRestrictionEnabled')"
|
|
/>
|
|
</div>
|
|
</template>
|
|
|
|
<template #cell-group="{ row }">
|
|
<div class="group/dropdown relative">
|
|
<button
|
|
:ref="(el) => setGroupButtonRef(row.id, el)"
|
|
@click="openGroupSelector(row)"
|
|
class="-mx-2 -my-1 flex cursor-pointer items-center gap-2 rounded-lg px-2 py-1 transition-all duration-200 hover:bg-gray-100 dark:hover:bg-dark-700"
|
|
:title="t('keys.clickToChangeGroup')"
|
|
>
|
|
<GroupBadge
|
|
v-if="row.group"
|
|
:name="row.group.name"
|
|
:platform="row.group.platform"
|
|
:subscription-type="row.group.subscription_type"
|
|
:rate-multiplier="row.group.rate_multiplier"
|
|
/>
|
|
<span v-else class="text-sm text-gray-400 dark:text-dark-500">{{
|
|
t('keys.noGroup')
|
|
}}</span>
|
|
<svg
|
|
class="h-3.5 w-3.5 text-gray-400 opacity-0 transition-opacity group-hover/dropdown:opacity-100"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
viewBox="0 0 24 24"
|
|
stroke-width="2"
|
|
>
|
|
<path
|
|
stroke-linecap="round"
|
|
stroke-linejoin="round"
|
|
d="M8.25 15L12 18.75 15.75 15m-7.5-6L12 5.25 15.75 9"
|
|
/>
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
</template>
|
|
|
|
<template #cell-usage="{ row }">
|
|
<div class="text-sm">
|
|
<div class="flex items-center gap-1.5">
|
|
<span class="text-gray-500 dark:text-gray-400">{{ t('keys.today') }}:</span>
|
|
<span class="font-medium text-gray-900 dark:text-white">
|
|
${{ (usageStats[row.id]?.today_actual_cost ?? 0).toFixed(4) }}
|
|
</span>
|
|
</div>
|
|
<div class="mt-0.5 flex items-center gap-1.5">
|
|
<span class="text-gray-500 dark:text-gray-400">{{ t('keys.total') }}:</span>
|
|
<span class="font-medium text-gray-900 dark:text-white">
|
|
${{ (usageStats[row.id]?.total_actual_cost ?? 0).toFixed(4) }}
|
|
</span>
|
|
</div>
|
|
<!-- Quota progress (if quota is set) -->
|
|
<div v-if="row.quota > 0" class="mt-1.5">
|
|
<div class="flex items-center gap-1.5">
|
|
<span class="text-gray-500 dark:text-gray-400">{{ t('keys.quota') }}:</span>
|
|
<span :class="[
|
|
'font-medium',
|
|
row.quota_used >= row.quota ? 'text-red-500' :
|
|
row.quota_used >= row.quota * 0.8 ? 'text-yellow-500' :
|
|
'text-gray-900 dark:text-white'
|
|
]">
|
|
${{ row.quota_used?.toFixed(2) || '0.00' }} / ${{ row.quota?.toFixed(2) }}
|
|
</span>
|
|
</div>
|
|
<div class="mt-1 h-1.5 w-full overflow-hidden rounded-full bg-gray-200 dark:bg-dark-600">
|
|
<div
|
|
:class="[
|
|
'h-full rounded-full transition-all',
|
|
row.quota_used >= row.quota ? 'bg-red-500' :
|
|
row.quota_used >= row.quota * 0.8 ? 'bg-yellow-500' :
|
|
'bg-primary-500'
|
|
]"
|
|
:style="{ width: Math.min((row.quota_used / row.quota) * 100, 100) + '%' }"
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<template #cell-expires_at="{ value }">
|
|
<span v-if="value" :class="[
|
|
'text-sm',
|
|
new Date(value) < new Date() ? 'text-red-500 dark:text-red-400' : 'text-gray-500 dark:text-dark-400'
|
|
]">
|
|
{{ formatDateTime(value) }}
|
|
</span>
|
|
<span v-else class="text-sm text-gray-400 dark:text-dark-500">{{ t('keys.noExpiration') }}</span>
|
|
</template>
|
|
|
|
<template #cell-status="{ value }">
|
|
<span :class="[
|
|
'badge',
|
|
value === 'active' ? 'badge-success' :
|
|
value === 'quota_exhausted' ? 'badge-warning' :
|
|
value === 'expired' ? 'badge-danger' :
|
|
'badge-gray'
|
|
]">
|
|
{{ t('keys.status.' + value) }}
|
|
</span>
|
|
</template>
|
|
|
|
<template #cell-created_at="{ value }">
|
|
<span class="text-sm text-gray-500 dark:text-dark-400">{{ formatDateTime(value) }}</span>
|
|
</template>
|
|
|
|
<template #cell-actions="{ row }">
|
|
<div class="flex items-center gap-1">
|
|
<!-- Use Key Button -->
|
|
<button
|
|
@click="openUseKeyModal(row)"
|
|
class="flex flex-col items-center gap-0.5 rounded-lg p-1.5 text-gray-500 transition-colors hover:bg-green-50 hover:text-green-600 dark:hover:bg-green-900/20 dark:hover:text-green-400"
|
|
>
|
|
<Icon name="terminal" size="sm" />
|
|
<span class="text-xs">{{ t('keys.useKey') }}</span>
|
|
</button>
|
|
<!-- Import to CC Switch Button -->
|
|
<button
|
|
v-if="!publicSettings?.hide_ccs_import_button"
|
|
@click="importToCcswitch(row)"
|
|
class="flex flex-col items-center gap-0.5 rounded-lg p-1.5 text-gray-500 transition-colors hover:bg-blue-50 hover:text-blue-600 dark:hover:bg-blue-900/20 dark:hover:text-blue-400"
|
|
>
|
|
<Icon name="upload" size="sm" />
|
|
<span class="text-xs">{{ t('keys.importToCcSwitch') }}</span>
|
|
</button>
|
|
<!-- Toggle Status Button -->
|
|
<button
|
|
@click="toggleKeyStatus(row)"
|
|
:class="[
|
|
'flex flex-col items-center gap-0.5 rounded-lg p-1.5 transition-colors',
|
|
row.status === 'active'
|
|
? 'text-gray-500 hover:bg-yellow-50 hover:text-yellow-600 dark:hover:bg-yellow-900/20 dark:hover:text-yellow-400'
|
|
: 'text-gray-500 hover:bg-green-50 hover:text-green-600 dark:hover:bg-green-900/20 dark:hover:text-green-400'
|
|
]"
|
|
>
|
|
<Icon v-if="row.status === 'active'" name="ban" size="sm" />
|
|
<Icon v-else name="checkCircle" size="sm" />
|
|
<span class="text-xs">{{ row.status === 'active' ? t('keys.disable') : t('keys.enable') }}</span>
|
|
</button>
|
|
<!-- Edit Button -->
|
|
<button
|
|
@click="editKey(row)"
|
|
class="flex flex-col items-center gap-0.5 rounded-lg p-1.5 text-gray-500 transition-colors hover:bg-gray-100 hover:text-primary-600 dark:hover:bg-dark-700 dark:hover:text-primary-400"
|
|
>
|
|
<Icon name="edit" size="sm" />
|
|
<span class="text-xs">{{ t('common.edit') }}</span>
|
|
</button>
|
|
<!-- Delete Button -->
|
|
<button
|
|
@click="confirmDelete(row)"
|
|
class="flex flex-col items-center gap-0.5 rounded-lg p-1.5 text-gray-500 transition-colors hover:bg-red-50 hover:text-red-600 dark:hover:bg-red-900/20 dark:hover:text-red-400"
|
|
>
|
|
<Icon name="trash" size="sm" />
|
|
<span class="text-xs">{{ t('common.delete') }}</span>
|
|
</button>
|
|
</div>
|
|
</template>
|
|
|
|
<template #empty>
|
|
<EmptyState
|
|
:title="t('keys.noKeysYet')"
|
|
:description="t('keys.createFirstKey')"
|
|
:action-text="t('keys.createKey')"
|
|
@action="showCreateModal = true"
|
|
/>
|
|
</template>
|
|
</DataTable>
|
|
</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>
|
|
|
|
<!-- Create/Edit Modal -->
|
|
<BaseDialog
|
|
:show="showCreateModal || showEditModal"
|
|
:title="showEditModal ? t('keys.editKey') : t('keys.createKey')"
|
|
width="normal"
|
|
@close="closeModals"
|
|
>
|
|
<form id="key-form" @submit.prevent="handleSubmit" class="space-y-5">
|
|
<div>
|
|
<label class="input-label">{{ t('keys.nameLabel') }}</label>
|
|
<input
|
|
v-model="formData.name"
|
|
type="text"
|
|
required
|
|
class="input"
|
|
:placeholder="t('keys.namePlaceholder')"
|
|
data-tour="key-form-name"
|
|
/>
|
|
</div>
|
|
|
|
<div>
|
|
<label class="input-label">{{ t('keys.groupLabel') }}</label>
|
|
<Select
|
|
v-model="formData.group_id"
|
|
:options="groupOptions"
|
|
:placeholder="t('keys.selectGroup')"
|
|
data-tour="key-form-group"
|
|
>
|
|
<template #selected="{ option }">
|
|
<GroupBadge
|
|
v-if="option"
|
|
:name="(option as unknown as GroupOption).label"
|
|
:platform="(option as unknown as GroupOption).platform"
|
|
:subscription-type="(option as unknown as GroupOption).subscriptionType"
|
|
:rate-multiplier="(option as unknown as GroupOption).rate"
|
|
/>
|
|
<span v-else class="text-gray-400">{{ t('keys.selectGroup') }}</span>
|
|
</template>
|
|
<template #option="{ option, selected }">
|
|
<GroupOptionItem
|
|
:name="(option as unknown as GroupOption).label"
|
|
:platform="(option as unknown as GroupOption).platform"
|
|
:subscription-type="(option as unknown as GroupOption).subscriptionType"
|
|
:rate-multiplier="(option as unknown as GroupOption).rate"
|
|
:description="(option as unknown as GroupOption).description"
|
|
:selected="selected"
|
|
/>
|
|
</template>
|
|
</Select>
|
|
</div>
|
|
|
|
<!-- Custom Key Section (only for create) -->
|
|
<div v-if="!showEditModal" class="space-y-3">
|
|
<div class="flex items-center justify-between">
|
|
<label class="input-label mb-0">{{ t('keys.customKeyLabel') }}</label>
|
|
<button
|
|
type="button"
|
|
@click="formData.use_custom_key = !formData.use_custom_key"
|
|
:class="[
|
|
'relative inline-flex h-5 w-9 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none',
|
|
formData.use_custom_key ? 'bg-primary-600' : 'bg-gray-200 dark:bg-dark-600'
|
|
]"
|
|
>
|
|
<span
|
|
:class="[
|
|
'pointer-events-none inline-block h-4 w-4 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out',
|
|
formData.use_custom_key ? 'translate-x-4' : 'translate-x-0'
|
|
]"
|
|
/>
|
|
</button>
|
|
</div>
|
|
<div v-if="formData.use_custom_key">
|
|
<input
|
|
v-model="formData.custom_key"
|
|
type="text"
|
|
class="input font-mono"
|
|
:placeholder="t('keys.customKeyPlaceholder')"
|
|
:class="{ 'border-red-500 dark:border-red-500': customKeyError }"
|
|
/>
|
|
<p v-if="customKeyError" class="mt-1 text-sm text-red-500">{{ customKeyError }}</p>
|
|
<p v-else class="input-hint">{{ t('keys.customKeyHint') }}</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div v-if="showEditModal">
|
|
<label class="input-label">{{ t('keys.statusLabel') }}</label>
|
|
<Select
|
|
v-model="formData.status"
|
|
:options="statusOptions"
|
|
:placeholder="t('keys.selectStatus')"
|
|
/>
|
|
</div>
|
|
|
|
<!-- IP Restriction Section -->
|
|
<div class="space-y-3">
|
|
<div class="flex items-center justify-between">
|
|
<label class="input-label mb-0">{{ t('keys.ipRestriction') }}</label>
|
|
<button
|
|
type="button"
|
|
@click="formData.enable_ip_restriction = !formData.enable_ip_restriction"
|
|
:class="[
|
|
'relative inline-flex h-5 w-9 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none',
|
|
formData.enable_ip_restriction ? 'bg-primary-600' : 'bg-gray-200 dark:bg-dark-600'
|
|
]"
|
|
>
|
|
<span
|
|
:class="[
|
|
'pointer-events-none inline-block h-4 w-4 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out',
|
|
formData.enable_ip_restriction ? 'translate-x-4' : 'translate-x-0'
|
|
]"
|
|
/>
|
|
</button>
|
|
</div>
|
|
|
|
<div v-if="formData.enable_ip_restriction" class="space-y-4 pt-2">
|
|
<div>
|
|
<label class="input-label">{{ t('keys.ipWhitelist') }}</label>
|
|
<textarea
|
|
v-model="formData.ip_whitelist"
|
|
rows="3"
|
|
class="input font-mono text-sm"
|
|
:placeholder="t('keys.ipWhitelistPlaceholder')"
|
|
/>
|
|
<p class="input-hint">{{ t('keys.ipWhitelistHint') }}</p>
|
|
</div>
|
|
|
|
<div>
|
|
<label class="input-label">{{ t('keys.ipBlacklist') }}</label>
|
|
<textarea
|
|
v-model="formData.ip_blacklist"
|
|
rows="3"
|
|
class="input font-mono text-sm"
|
|
:placeholder="t('keys.ipBlacklistPlaceholder')"
|
|
/>
|
|
<p class="input-hint">{{ t('keys.ipBlacklistHint') }}</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Quota Limit Section -->
|
|
<div class="space-y-3">
|
|
<label class="input-label">{{ t('keys.quotaLimit') }}</label>
|
|
<!-- Switch commented out - always show input, 0 = unlimited
|
|
<div class="flex items-center justify-between">
|
|
<label class="input-label mb-0">{{ t('keys.quotaLimit') }}</label>
|
|
<button
|
|
type="button"
|
|
@click="formData.enable_quota = !formData.enable_quota"
|
|
:class="[
|
|
'relative inline-flex h-5 w-9 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none',
|
|
formData.enable_quota ? 'bg-primary-600' : 'bg-gray-200 dark:bg-dark-600'
|
|
]"
|
|
>
|
|
<span
|
|
:class="[
|
|
'pointer-events-none inline-block h-4 w-4 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out',
|
|
formData.enable_quota ? 'translate-x-4' : 'translate-x-0'
|
|
]"
|
|
/>
|
|
</button>
|
|
</div>
|
|
-->
|
|
|
|
<div class="space-y-4">
|
|
<div>
|
|
<div class="relative">
|
|
<span class="absolute left-3 top-1/2 -translate-y-1/2 text-gray-500">$</span>
|
|
<input
|
|
v-model.number="formData.quota"
|
|
type="number"
|
|
step="0.01"
|
|
min="0"
|
|
class="input pl-7"
|
|
:placeholder="t('keys.quotaAmountPlaceholder')"
|
|
/>
|
|
</div>
|
|
<p class="input-hint">{{ t('keys.quotaAmountHint') }}</p>
|
|
</div>
|
|
|
|
<!-- Quota used display (only in edit mode) -->
|
|
<div v-if="showEditModal && selectedKey && selectedKey.quota > 0">
|
|
<label class="input-label">{{ t('keys.quotaUsed') }}</label>
|
|
<div class="flex items-center gap-2">
|
|
<div class="flex-1 rounded-lg bg-gray-100 px-3 py-2 dark:bg-dark-700">
|
|
<span class="font-medium text-gray-900 dark:text-white">
|
|
${{ selectedKey.quota_used?.toFixed(4) || '0.0000' }}
|
|
</span>
|
|
<span class="mx-2 text-gray-400">/</span>
|
|
<span class="text-gray-500 dark:text-gray-400">
|
|
${{ selectedKey.quota?.toFixed(2) || '0.00' }}
|
|
</span>
|
|
</div>
|
|
<button
|
|
type="button"
|
|
@click="confirmResetQuota"
|
|
class="btn btn-secondary text-sm"
|
|
:title="t('keys.resetQuotaUsed')"
|
|
>
|
|
{{ t('keys.reset') }}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Expiration Section -->
|
|
<div class="space-y-3">
|
|
<div class="flex items-center justify-between">
|
|
<label class="input-label mb-0">{{ t('keys.expiration') }}</label>
|
|
<button
|
|
type="button"
|
|
@click="formData.enable_expiration = !formData.enable_expiration"
|
|
:class="[
|
|
'relative inline-flex h-5 w-9 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none',
|
|
formData.enable_expiration ? 'bg-primary-600' : 'bg-gray-200 dark:bg-dark-600'
|
|
]"
|
|
>
|
|
<span
|
|
:class="[
|
|
'pointer-events-none inline-block h-4 w-4 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out',
|
|
formData.enable_expiration ? 'translate-x-4' : 'translate-x-0'
|
|
]"
|
|
/>
|
|
</button>
|
|
</div>
|
|
|
|
<div v-if="formData.enable_expiration" class="space-y-4 pt-2">
|
|
<!-- Quick select buttons (for both create and edit mode) -->
|
|
<div class="flex flex-wrap gap-2">
|
|
<button
|
|
v-for="days in ['7', '30', '90']"
|
|
:key="days"
|
|
type="button"
|
|
@click="setExpirationDays(parseInt(days))"
|
|
:class="[
|
|
'rounded-lg px-3 py-1.5 text-sm transition-colors',
|
|
formData.expiration_preset === days
|
|
? 'bg-primary-100 text-primary-700 dark:bg-primary-900/30 dark:text-primary-400'
|
|
: 'bg-gray-100 text-gray-600 hover:bg-gray-200 dark:bg-dark-700 dark:text-gray-400 dark:hover:bg-dark-600'
|
|
]"
|
|
>
|
|
{{ showEditModal ? t('keys.extendDays', { days }) : t('keys.expiresInDays', { days }) }}
|
|
</button>
|
|
<button
|
|
type="button"
|
|
@click="formData.expiration_preset = 'custom'"
|
|
:class="[
|
|
'rounded-lg px-3 py-1.5 text-sm transition-colors',
|
|
formData.expiration_preset === 'custom'
|
|
? 'bg-primary-100 text-primary-700 dark:bg-primary-900/30 dark:text-primary-400'
|
|
: 'bg-gray-100 text-gray-600 hover:bg-gray-200 dark:bg-dark-700 dark:text-gray-400 dark:hover:bg-dark-600'
|
|
]"
|
|
>
|
|
{{ t('keys.customDate') }}
|
|
</button>
|
|
</div>
|
|
|
|
<!-- Date picker (always show for precise adjustment) -->
|
|
<div>
|
|
<label class="input-label">{{ t('keys.expirationDate') }}</label>
|
|
<input
|
|
v-model="formData.expiration_date"
|
|
type="datetime-local"
|
|
class="input"
|
|
/>
|
|
<p class="input-hint">{{ t('keys.expirationDateHint') }}</p>
|
|
</div>
|
|
|
|
<!-- Current expiration display (only in edit mode) -->
|
|
<div v-if="showEditModal && selectedKey?.expires_at" class="text-sm">
|
|
<span class="text-gray-500 dark:text-gray-400">{{ t('keys.currentExpiration') }}: </span>
|
|
<span class="font-medium text-gray-900 dark:text-white">
|
|
{{ formatDateTime(selectedKey.expires_at) }}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</form>
|
|
<template #footer>
|
|
<div class="flex justify-end gap-3">
|
|
<button @click="closeModals" type="button" class="btn btn-secondary">
|
|
{{ t('common.cancel') }}
|
|
</button>
|
|
<button
|
|
form="key-form"
|
|
type="submit"
|
|
:disabled="submitting"
|
|
class="btn btn-primary"
|
|
data-tour="key-form-submit"
|
|
>
|
|
<svg
|
|
v-if="submitting"
|
|
class="-ml-1 mr-2 h-4 w-4 animate-spin"
|
|
fill="none"
|
|
viewBox="0 0 24 24"
|
|
>
|
|
<circle
|
|
class="opacity-25"
|
|
cx="12"
|
|
cy="12"
|
|
r="10"
|
|
stroke="currentColor"
|
|
stroke-width="4"
|
|
></circle>
|
|
<path
|
|
class="opacity-75"
|
|
fill="currentColor"
|
|
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
|
></path>
|
|
</svg>
|
|
{{
|
|
submitting
|
|
? t('keys.saving')
|
|
: showEditModal
|
|
? t('common.update')
|
|
: t('common.create')
|
|
}}
|
|
</button>
|
|
</div>
|
|
</template>
|
|
</BaseDialog>
|
|
|
|
<!-- Delete Confirmation Dialog -->
|
|
<ConfirmDialog
|
|
:show="showDeleteDialog"
|
|
:title="t('keys.deleteKey')"
|
|
:message="t('keys.deleteConfirmMessage', { name: selectedKey?.name })"
|
|
:confirm-text="t('common.delete')"
|
|
:cancel-text="t('common.cancel')"
|
|
:danger="true"
|
|
@confirm="handleDelete"
|
|
@cancel="showDeleteDialog = false"
|
|
/>
|
|
|
|
<!-- Reset Quota Confirmation Dialog -->
|
|
<ConfirmDialog
|
|
:show="showResetQuotaDialog"
|
|
:title="t('keys.resetQuotaTitle')"
|
|
:message="t('keys.resetQuotaConfirmMessage', { name: selectedKey?.name, used: selectedKey?.quota_used?.toFixed(4) })"
|
|
:confirm-text="t('keys.reset')"
|
|
:cancel-text="t('common.cancel')"
|
|
:danger="true"
|
|
@confirm="resetQuotaUsed"
|
|
@cancel="showResetQuotaDialog = false"
|
|
/>
|
|
|
|
<!-- Use Key Modal -->
|
|
<UseKeyModal
|
|
:show="showUseKeyModal"
|
|
:api-key="selectedKey?.key || ''"
|
|
:base-url="publicSettings?.api_base_url || ''"
|
|
:platform="selectedKey?.group?.platform || null"
|
|
@close="closeUseKeyModal"
|
|
/>
|
|
|
|
<!-- CCS Client Selection Dialog for Antigravity -->
|
|
<BaseDialog
|
|
:show="showCcsClientSelect"
|
|
:title="t('keys.ccsClientSelect.title')"
|
|
width="narrow"
|
|
@close="closeCcsClientSelect"
|
|
>
|
|
<div class="space-y-4">
|
|
<p class="text-sm text-gray-600 dark:text-gray-400">
|
|
{{ t('keys.ccsClientSelect.description') }}
|
|
</p>
|
|
<div class="grid grid-cols-2 gap-3">
|
|
<button
|
|
@click="handleCcsClientSelect('claude')"
|
|
class="flex flex-col items-center gap-2 p-4 rounded-xl border-2 border-gray-200 dark:border-dark-600 hover:border-primary-500 dark:hover:border-primary-500 hover:bg-primary-50 dark:hover:bg-primary-900/20 transition-all"
|
|
>
|
|
<Icon name="terminal" size="xl" class="text-gray-600 dark:text-gray-400" />
|
|
<span class="font-medium text-gray-900 dark:text-white">{{
|
|
t('keys.ccsClientSelect.claudeCode')
|
|
}}</span>
|
|
<span class="text-xs text-gray-500 dark:text-gray-400">{{
|
|
t('keys.ccsClientSelect.claudeCodeDesc')
|
|
}}</span>
|
|
</button>
|
|
<button
|
|
@click="handleCcsClientSelect('gemini')"
|
|
class="flex flex-col items-center gap-2 p-4 rounded-xl border-2 border-gray-200 dark:border-dark-600 hover:border-primary-500 dark:hover:border-primary-500 hover:bg-primary-50 dark:hover:bg-primary-900/20 transition-all"
|
|
>
|
|
<Icon name="sparkles" size="xl" class="text-gray-600 dark:text-gray-400" />
|
|
<span class="font-medium text-gray-900 dark:text-white">{{
|
|
t('keys.ccsClientSelect.geminiCli')
|
|
}}</span>
|
|
<span class="text-xs text-gray-500 dark:text-gray-400">{{
|
|
t('keys.ccsClientSelect.geminiCliDesc')
|
|
}}</span>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<template #footer>
|
|
<div class="flex justify-end">
|
|
<button @click="closeCcsClientSelect" class="btn btn-secondary">
|
|
{{ t('common.cancel') }}
|
|
</button>
|
|
</div>
|
|
</template>
|
|
</BaseDialog>
|
|
|
|
<!-- Group Selector Dropdown (Teleported to body to avoid overflow clipping) -->
|
|
<Teleport to="body">
|
|
<div
|
|
v-if="groupSelectorKeyId !== null && dropdownPosition"
|
|
ref="dropdownRef"
|
|
class="animate-in fade-in slide-in-from-top-2 fixed z-[100000020] w-64 overflow-hidden rounded-xl bg-white shadow-lg ring-1 ring-black/5 duration-200 dark:bg-dark-800 dark:ring-white/10"
|
|
style="pointer-events: auto !important;"
|
|
:style="{ top: dropdownPosition.top + 'px', left: dropdownPosition.left + 'px' }"
|
|
>
|
|
<div class="max-h-64 overflow-y-auto p-1.5">
|
|
<button
|
|
v-for="option in groupOptions"
|
|
:key="option.value ?? 'null'"
|
|
@click="changeGroup(selectedKeyForGroup!, option.value)"
|
|
:class="[
|
|
'flex w-full items-center justify-between rounded-lg px-3 py-2 text-sm transition-colors',
|
|
selectedKeyForGroup?.group_id === option.value ||
|
|
(!selectedKeyForGroup?.group_id && option.value === null)
|
|
? 'bg-primary-50 dark:bg-primary-900/20'
|
|
: 'hover:bg-gray-100 dark:hover:bg-dark-700'
|
|
]"
|
|
:title="option.description || undefined"
|
|
>
|
|
<GroupOptionItem
|
|
:name="option.label"
|
|
:platform="option.platform"
|
|
:subscription-type="option.subscriptionType"
|
|
:rate-multiplier="option.rate"
|
|
:description="option.description"
|
|
:selected="
|
|
selectedKeyForGroup?.group_id === option.value ||
|
|
(!selectedKeyForGroup?.group_id && option.value === null)
|
|
"
|
|
/>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</Teleport>
|
|
</AppLayout>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import { ref, computed, onMounted, onUnmounted, type ComponentPublicInstance } from 'vue'
|
|
import { useI18n } from 'vue-i18n'
|
|
import { useAppStore } from '@/stores/app'
|
|
import { useOnboardingStore } from '@/stores/onboarding'
|
|
import { useClipboard } from '@/composables/useClipboard'
|
|
|
|
const { t } = useI18n()
|
|
import { keysAPI, authAPI, usageAPI, userGroupsAPI } from '@/api'
|
|
import AppLayout from '@/components/layout/AppLayout.vue'
|
|
import TablePageLayout from '@/components/layout/TablePageLayout.vue'
|
|
import DataTable from '@/components/common/DataTable.vue'
|
|
import Pagination from '@/components/common/Pagination.vue'
|
|
import BaseDialog from '@/components/common/BaseDialog.vue'
|
|
import ConfirmDialog from '@/components/common/ConfirmDialog.vue'
|
|
import EmptyState from '@/components/common/EmptyState.vue'
|
|
import Select from '@/components/common/Select.vue'
|
|
import Icon from '@/components/icons/Icon.vue'
|
|
import UseKeyModal from '@/components/keys/UseKeyModal.vue'
|
|
import GroupBadge from '@/components/common/GroupBadge.vue'
|
|
import GroupOptionItem from '@/components/common/GroupOptionItem.vue'
|
|
import type { ApiKey, Group, PublicSettings, SubscriptionType, GroupPlatform } from '@/types'
|
|
import type { Column } from '@/components/common/types'
|
|
import type { BatchApiKeyUsageStats } from '@/api/usage'
|
|
import { formatDateTime } from '@/utils/format'
|
|
|
|
// Helper to format date for datetime-local input
|
|
const formatDateTimeLocal = (isoDate: string): string => {
|
|
const date = new Date(isoDate)
|
|
const pad = (n: number) => n.toString().padStart(2, '0')
|
|
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(date.getHours())}:${pad(date.getMinutes())}`
|
|
}
|
|
|
|
interface GroupOption {
|
|
value: number
|
|
label: string
|
|
description: string | null
|
|
rate: number
|
|
subscriptionType: SubscriptionType
|
|
platform: GroupPlatform
|
|
}
|
|
|
|
const appStore = useAppStore()
|
|
const onboardingStore = useOnboardingStore()
|
|
const { copyToClipboard: clipboardCopy } = useClipboard()
|
|
|
|
const columns = computed<Column[]>(() => [
|
|
{ key: 'name', label: t('common.name'), sortable: true },
|
|
{ key: 'key', label: t('keys.apiKey'), sortable: false },
|
|
{ key: 'group', label: t('keys.group'), sortable: false },
|
|
{ key: 'usage', label: t('keys.usage'), sortable: false },
|
|
{ key: 'expires_at', label: t('keys.expiresAt'), sortable: true },
|
|
{ key: 'status', label: t('common.status'), sortable: true },
|
|
{ key: 'created_at', label: t('keys.created'), sortable: true },
|
|
{ key: 'actions', label: t('common.actions'), sortable: false }
|
|
])
|
|
|
|
const apiKeys = ref<ApiKey[]>([])
|
|
const groups = ref<Group[]>([])
|
|
const loading = ref(false)
|
|
const submitting = ref(false)
|
|
const usageStats = ref<Record<string, BatchApiKeyUsageStats>>({})
|
|
|
|
const pagination = ref({
|
|
page: 1,
|
|
page_size: 10,
|
|
total: 0,
|
|
pages: 0
|
|
})
|
|
|
|
const showCreateModal = ref(false)
|
|
const showEditModal = ref(false)
|
|
const showDeleteDialog = ref(false)
|
|
const showResetQuotaDialog = ref(false)
|
|
const showUseKeyModal = ref(false)
|
|
const showCcsClientSelect = ref(false)
|
|
const pendingCcsRow = ref<ApiKey | null>(null)
|
|
const selectedKey = ref<ApiKey | null>(null)
|
|
const copiedKeyId = ref<number | null>(null)
|
|
const groupSelectorKeyId = ref<number | null>(null)
|
|
const publicSettings = ref<PublicSettings | null>(null)
|
|
const dropdownRef = ref<HTMLElement | null>(null)
|
|
const dropdownPosition = ref<{ top: number; left: number } | null>(null)
|
|
const groupButtonRefs = ref<Map<number, HTMLElement>>(new Map())
|
|
let abortController: AbortController | null = null
|
|
|
|
// Get the currently selected key for group change
|
|
const selectedKeyForGroup = computed(() => {
|
|
if (groupSelectorKeyId.value === null) return null
|
|
return apiKeys.value.find((k) => k.id === groupSelectorKeyId.value) || null
|
|
})
|
|
|
|
const setGroupButtonRef = (keyId: number, el: Element | ComponentPublicInstance | null) => {
|
|
if (el instanceof HTMLElement) {
|
|
groupButtonRefs.value.set(keyId, el)
|
|
} else {
|
|
groupButtonRefs.value.delete(keyId)
|
|
}
|
|
}
|
|
|
|
const formData = ref({
|
|
name: '',
|
|
group_id: null as number | null,
|
|
status: 'active' as 'active' | 'inactive',
|
|
use_custom_key: false,
|
|
custom_key: '',
|
|
enable_ip_restriction: false,
|
|
ip_whitelist: '',
|
|
ip_blacklist: '',
|
|
// Quota settings (empty = unlimited)
|
|
enable_quota: false,
|
|
quota: null as number | null,
|
|
enable_expiration: false,
|
|
expiration_preset: '30' as '7' | '30' | '90' | 'custom',
|
|
expiration_date: ''
|
|
})
|
|
|
|
// 自定义Key验证
|
|
const customKeyError = computed(() => {
|
|
if (!formData.value.use_custom_key || !formData.value.custom_key) {
|
|
return ''
|
|
}
|
|
const key = formData.value.custom_key
|
|
if (key.length < 16) {
|
|
return t('keys.customKeyTooShort')
|
|
}
|
|
// 检查字符:只允许字母、数字、下划线、连字符
|
|
if (!/^[a-zA-Z0-9_-]+$/.test(key)) {
|
|
return t('keys.customKeyInvalidChars')
|
|
}
|
|
return ''
|
|
})
|
|
|
|
const statusOptions = computed(() => [
|
|
{ value: 'active', label: t('common.active') },
|
|
{ value: 'inactive', label: t('common.inactive') }
|
|
])
|
|
|
|
// Convert groups to Select options format with rate multiplier and subscription type
|
|
const groupOptions = computed(() =>
|
|
groups.value.map((group) => ({
|
|
value: group.id,
|
|
label: group.name,
|
|
description: group.description,
|
|
rate: group.rate_multiplier,
|
|
subscriptionType: group.subscription_type,
|
|
platform: group.platform
|
|
}))
|
|
)
|
|
|
|
const maskKey = (key: string): string => {
|
|
if (key.length <= 12) return key
|
|
return `${key.slice(0, 8)}...${key.slice(-4)}`
|
|
}
|
|
|
|
const copyToClipboard = async (text: string, keyId: number) => {
|
|
const success = await clipboardCopy(text, t('keys.copied'))
|
|
if (success) {
|
|
copiedKeyId.value = keyId
|
|
setTimeout(() => {
|
|
copiedKeyId.value = null
|
|
}, 800)
|
|
}
|
|
}
|
|
|
|
const isAbortError = (error: unknown) => {
|
|
if (!error || typeof error !== 'object') return false
|
|
const { name, code } = error as { name?: string; code?: string }
|
|
return name === 'AbortError' || code === 'ERR_CANCELED'
|
|
}
|
|
|
|
const loadApiKeys = async () => {
|
|
abortController?.abort()
|
|
const controller = new AbortController()
|
|
abortController = controller
|
|
const { signal } = controller
|
|
loading.value = true
|
|
try {
|
|
const response = await keysAPI.list(pagination.value.page, pagination.value.page_size, {
|
|
signal
|
|
})
|
|
if (signal.aborted) return
|
|
apiKeys.value = response.items
|
|
pagination.value.total = response.total
|
|
pagination.value.pages = response.pages
|
|
|
|
// Load usage stats for all API keys in the list
|
|
if (response.items.length > 0) {
|
|
const keyIds = response.items.map((k) => k.id)
|
|
try {
|
|
const usageResponse = await usageAPI.getDashboardApiKeysUsage(keyIds, { signal })
|
|
if (signal.aborted) return
|
|
usageStats.value = usageResponse.stats
|
|
} catch (e) {
|
|
if (!isAbortError(e)) {
|
|
console.error('Failed to load usage stats:', e)
|
|
}
|
|
}
|
|
}
|
|
} catch (error) {
|
|
if (isAbortError(error)) {
|
|
return
|
|
}
|
|
appStore.showError(t('keys.failedToLoad'))
|
|
} finally {
|
|
if (abortController === controller) {
|
|
loading.value = false
|
|
}
|
|
}
|
|
}
|
|
|
|
const loadGroups = async () => {
|
|
try {
|
|
groups.value = await userGroupsAPI.getAvailable()
|
|
} catch (error) {
|
|
console.error('Failed to load groups:', error)
|
|
}
|
|
}
|
|
|
|
const loadPublicSettings = async () => {
|
|
try {
|
|
publicSettings.value = await authAPI.getPublicSettings()
|
|
} catch (error) {
|
|
console.error('Failed to load public settings:', error)
|
|
}
|
|
}
|
|
|
|
const openUseKeyModal = (key: ApiKey) => {
|
|
selectedKey.value = key
|
|
showUseKeyModal.value = true
|
|
}
|
|
|
|
const closeUseKeyModal = () => {
|
|
showUseKeyModal.value = false
|
|
selectedKey.value = null
|
|
}
|
|
|
|
const handlePageChange = (page: number) => {
|
|
pagination.value.page = page
|
|
loadApiKeys()
|
|
}
|
|
|
|
const handlePageSizeChange = (pageSize: number) => {
|
|
pagination.value.page_size = pageSize
|
|
pagination.value.page = 1
|
|
loadApiKeys()
|
|
}
|
|
|
|
const editKey = (key: ApiKey) => {
|
|
selectedKey.value = key
|
|
const hasIPRestriction = (key.ip_whitelist?.length > 0) || (key.ip_blacklist?.length > 0)
|
|
const hasExpiration = !!key.expires_at
|
|
formData.value = {
|
|
name: key.name,
|
|
group_id: key.group_id,
|
|
status: key.status === 'quota_exhausted' || key.status === 'expired' ? 'inactive' : key.status,
|
|
use_custom_key: false,
|
|
custom_key: '',
|
|
enable_ip_restriction: hasIPRestriction,
|
|
ip_whitelist: (key.ip_whitelist || []).join('\n'),
|
|
ip_blacklist: (key.ip_blacklist || []).join('\n'),
|
|
enable_quota: key.quota > 0,
|
|
quota: key.quota > 0 ? key.quota : null,
|
|
enable_expiration: hasExpiration,
|
|
expiration_preset: 'custom',
|
|
expiration_date: key.expires_at ? formatDateTimeLocal(key.expires_at) : ''
|
|
}
|
|
showEditModal.value = true
|
|
}
|
|
|
|
const toggleKeyStatus = async (key: ApiKey) => {
|
|
const newStatus = key.status === 'active' ? 'inactive' : 'active'
|
|
try {
|
|
await keysAPI.toggleStatus(key.id, newStatus)
|
|
appStore.showSuccess(
|
|
newStatus === 'active' ? t('keys.keyEnabledSuccess') : t('keys.keyDisabledSuccess')
|
|
)
|
|
loadApiKeys()
|
|
} catch (error) {
|
|
appStore.showError(t('keys.failedToUpdateStatus'))
|
|
}
|
|
}
|
|
|
|
const openGroupSelector = (key: ApiKey) => {
|
|
if (groupSelectorKeyId.value === key.id) {
|
|
groupSelectorKeyId.value = null
|
|
dropdownPosition.value = null
|
|
} else {
|
|
const buttonEl = groupButtonRefs.value.get(key.id)
|
|
if (buttonEl) {
|
|
const rect = buttonEl.getBoundingClientRect()
|
|
dropdownPosition.value = {
|
|
top: rect.bottom + 4,
|
|
left: rect.left
|
|
}
|
|
}
|
|
groupSelectorKeyId.value = key.id
|
|
}
|
|
}
|
|
|
|
const changeGroup = async (key: ApiKey, newGroupId: number | null) => {
|
|
groupSelectorKeyId.value = null
|
|
dropdownPosition.value = null
|
|
if (key.group_id === newGroupId) return
|
|
|
|
try {
|
|
await keysAPI.update(key.id, { group_id: newGroupId })
|
|
appStore.showSuccess(t('keys.groupChangedSuccess'))
|
|
loadApiKeys()
|
|
} catch (error) {
|
|
appStore.showError(t('keys.failedToChangeGroup'))
|
|
}
|
|
}
|
|
|
|
const closeGroupSelector = (event: MouseEvent) => {
|
|
const target = event.target as HTMLElement
|
|
// Check if click is inside the dropdown or the trigger button
|
|
if (!target.closest('.group\\/dropdown') && !dropdownRef.value?.contains(target)) {
|
|
groupSelectorKeyId.value = null
|
|
dropdownPosition.value = null
|
|
}
|
|
}
|
|
|
|
const confirmDelete = (key: ApiKey) => {
|
|
selectedKey.value = key
|
|
showDeleteDialog.value = true
|
|
}
|
|
|
|
const handleSubmit = async () => {
|
|
// Validate group_id is required
|
|
if (formData.value.group_id === null) {
|
|
appStore.showError(t('keys.groupRequired'))
|
|
return
|
|
}
|
|
|
|
// Validate custom key if enabled
|
|
if (!showEditModal.value && formData.value.use_custom_key) {
|
|
if (!formData.value.custom_key) {
|
|
appStore.showError(t('keys.customKeyRequired'))
|
|
return
|
|
}
|
|
if (customKeyError.value) {
|
|
appStore.showError(customKeyError.value)
|
|
return
|
|
}
|
|
}
|
|
|
|
// Parse IP lists only if IP restriction is enabled
|
|
const parseIPList = (text: string): string[] =>
|
|
text.split('\n').map(ip => ip.trim()).filter(ip => ip.length > 0)
|
|
const ipWhitelist = formData.value.enable_ip_restriction ? parseIPList(formData.value.ip_whitelist) : []
|
|
const ipBlacklist = formData.value.enable_ip_restriction ? parseIPList(formData.value.ip_blacklist) : []
|
|
|
|
// Calculate quota value (null/empty/0 = unlimited, stored as 0)
|
|
const quota = formData.value.quota && formData.value.quota > 0 ? formData.value.quota : 0
|
|
|
|
// Calculate expiration
|
|
let expiresInDays: number | undefined
|
|
let expiresAt: string | null | undefined
|
|
if (formData.value.enable_expiration && formData.value.expiration_date) {
|
|
if (!showEditModal.value) {
|
|
// Create mode: calculate days from date
|
|
const expDate = new Date(formData.value.expiration_date)
|
|
const now = new Date()
|
|
const diffDays = Math.ceil((expDate.getTime() - now.getTime()) / (1000 * 60 * 60 * 24))
|
|
expiresInDays = diffDays > 0 ? diffDays : 1
|
|
} else {
|
|
// Edit mode: use custom date directly
|
|
expiresAt = new Date(formData.value.expiration_date).toISOString()
|
|
}
|
|
} else if (showEditModal.value) {
|
|
// Edit mode: if expiration disabled or date cleared, send empty string to clear
|
|
expiresAt = ''
|
|
}
|
|
|
|
submitting.value = true
|
|
try {
|
|
if (showEditModal.value && selectedKey.value) {
|
|
await keysAPI.update(selectedKey.value.id, {
|
|
name: formData.value.name,
|
|
group_id: formData.value.group_id,
|
|
status: formData.value.status,
|
|
ip_whitelist: ipWhitelist,
|
|
ip_blacklist: ipBlacklist,
|
|
quota: quota,
|
|
expires_at: expiresAt
|
|
})
|
|
appStore.showSuccess(t('keys.keyUpdatedSuccess'))
|
|
} else {
|
|
const customKey = formData.value.use_custom_key ? formData.value.custom_key : undefined
|
|
await keysAPI.create(
|
|
formData.value.name,
|
|
formData.value.group_id,
|
|
customKey,
|
|
ipWhitelist,
|
|
ipBlacklist,
|
|
quota,
|
|
expiresInDays
|
|
)
|
|
appStore.showSuccess(t('keys.keyCreatedSuccess'))
|
|
// Only advance tour if active, on submit step, and creation succeeded
|
|
if (onboardingStore.isCurrentStep('[data-tour="key-form-submit"]')) {
|
|
onboardingStore.nextStep(500)
|
|
}
|
|
}
|
|
closeModals()
|
|
loadApiKeys()
|
|
} catch (error: any) {
|
|
const errorMsg = error.response?.data?.detail || t('keys.failedToSave')
|
|
appStore.showError(errorMsg)
|
|
// Don't advance tour on error
|
|
} finally {
|
|
submitting.value = false
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 处理删除 API Key 的操作
|
|
* 优化:错误处理改进,优先显示后端返回的具体错误消息(如权限不足等),
|
|
* 若后端未返回消息则显示默认的国际化文本
|
|
*/
|
|
const handleDelete = async () => {
|
|
if (!selectedKey.value) return
|
|
|
|
try {
|
|
await keysAPI.delete(selectedKey.value.id)
|
|
appStore.showSuccess(t('keys.keyDeletedSuccess'))
|
|
showDeleteDialog.value = false
|
|
loadApiKeys()
|
|
} catch (error: any) {
|
|
// 优先使用后端返回的错误消息,提供更具体的错误信息给用户
|
|
const errorMsg = error?.message || t('keys.failedToDelete')
|
|
appStore.showError(errorMsg)
|
|
}
|
|
}
|
|
|
|
const closeModals = () => {
|
|
showCreateModal.value = false
|
|
showEditModal.value = false
|
|
selectedKey.value = null
|
|
formData.value = {
|
|
name: '',
|
|
group_id: null,
|
|
status: 'active',
|
|
use_custom_key: false,
|
|
custom_key: '',
|
|
enable_ip_restriction: false,
|
|
ip_whitelist: '',
|
|
ip_blacklist: '',
|
|
enable_quota: false,
|
|
quota: null,
|
|
enable_expiration: false,
|
|
expiration_preset: '30',
|
|
expiration_date: ''
|
|
}
|
|
}
|
|
|
|
// Show reset quota confirmation dialog
|
|
const confirmResetQuota = () => {
|
|
showResetQuotaDialog.value = true
|
|
}
|
|
|
|
// Set expiration date based on quick select days
|
|
const setExpirationDays = (days: number) => {
|
|
formData.value.expiration_preset = days.toString() as '7' | '30' | '90'
|
|
const expDate = new Date()
|
|
expDate.setDate(expDate.getDate() + days)
|
|
formData.value.expiration_date = formatDateTimeLocal(expDate.toISOString())
|
|
}
|
|
|
|
// Reset quota used for an API key
|
|
const resetQuotaUsed = async () => {
|
|
if (!selectedKey.value) return
|
|
showResetQuotaDialog.value = false
|
|
try {
|
|
await keysAPI.update(selectedKey.value.id, { reset_quota: true })
|
|
appStore.showSuccess(t('keys.quotaResetSuccess'))
|
|
// Update local state
|
|
if (selectedKey.value) {
|
|
selectedKey.value.quota_used = 0
|
|
}
|
|
} catch (error: any) {
|
|
const errorMsg = error.response?.data?.detail || t('keys.failedToResetQuota')
|
|
appStore.showError(errorMsg)
|
|
}
|
|
}
|
|
|
|
const importToCcswitch = (row: ApiKey) => {
|
|
const platform = row.group?.platform || 'anthropic'
|
|
|
|
// For antigravity platform, show client selection dialog
|
|
if (platform === 'antigravity') {
|
|
pendingCcsRow.value = row
|
|
showCcsClientSelect.value = true
|
|
return
|
|
}
|
|
|
|
// For other platforms, execute directly
|
|
executeCcsImport(row, platform === 'gemini' ? 'gemini' : 'claude')
|
|
}
|
|
|
|
const executeCcsImport = (row: ApiKey, clientType: 'claude' | 'gemini') => {
|
|
const baseUrl = publicSettings.value?.api_base_url || window.location.origin
|
|
const platform = row.group?.platform || 'anthropic'
|
|
|
|
// Determine app name and endpoint based on platform and client type
|
|
let app: string
|
|
let endpoint: string
|
|
|
|
if (platform === 'antigravity') {
|
|
// Antigravity always uses /antigravity suffix
|
|
app = clientType === 'gemini' ? 'gemini' : 'claude'
|
|
endpoint = `${baseUrl}/antigravity`
|
|
} else {
|
|
switch (platform) {
|
|
case 'openai':
|
|
app = 'codex'
|
|
endpoint = baseUrl
|
|
break
|
|
case 'gemini':
|
|
app = 'gemini'
|
|
endpoint = baseUrl
|
|
break
|
|
default: // anthropic
|
|
app = 'claude'
|
|
endpoint = baseUrl
|
|
}
|
|
}
|
|
|
|
const usageScript = `({
|
|
request: {
|
|
url: "{{baseUrl}}/v1/usage",
|
|
method: "GET",
|
|
headers: { "Authorization": "Bearer {{apiKey}}" }
|
|
},
|
|
extractor: function(response) {
|
|
return {
|
|
isValid: response.is_active || true,
|
|
remaining: response.balance,
|
|
unit: "USD"
|
|
};
|
|
}
|
|
})`
|
|
const params = new URLSearchParams({
|
|
resource: 'provider',
|
|
app: app,
|
|
name: 'sub2api',
|
|
homepage: baseUrl,
|
|
endpoint: endpoint,
|
|
apiKey: row.key,
|
|
configFormat: 'json',
|
|
usageEnabled: 'true',
|
|
usageScript: btoa(usageScript),
|
|
usageAutoInterval: '30'
|
|
})
|
|
const deeplink = `ccswitch://v1/import?${params.toString()}`
|
|
|
|
try {
|
|
window.open(deeplink, '_self')
|
|
|
|
// Check if the protocol handler worked by detecting if we're still focused
|
|
setTimeout(() => {
|
|
if (document.hasFocus()) {
|
|
// Still focused means the protocol handler likely failed
|
|
appStore.showError(t('keys.ccSwitchNotInstalled'))
|
|
}
|
|
}, 100)
|
|
} catch (error) {
|
|
appStore.showError(t('keys.ccSwitchNotInstalled'))
|
|
}
|
|
}
|
|
|
|
const handleCcsClientSelect = (clientType: 'claude' | 'gemini') => {
|
|
if (pendingCcsRow.value) {
|
|
executeCcsImport(pendingCcsRow.value, clientType)
|
|
}
|
|
showCcsClientSelect.value = false
|
|
pendingCcsRow.value = null
|
|
}
|
|
|
|
const closeCcsClientSelect = () => {
|
|
showCcsClientSelect.value = false
|
|
pendingCcsRow.value = null
|
|
}
|
|
|
|
onMounted(() => {
|
|
loadApiKeys()
|
|
loadGroups()
|
|
loadPublicSettings()
|
|
document.addEventListener('click', closeGroupSelector)
|
|
})
|
|
|
|
onUnmounted(() => {
|
|
document.removeEventListener('click', closeGroupSelector)
|
|
})
|
|
</script>
|