First commit
This commit is contained in:
733
frontend/src/views/user/KeysView.vue
Normal file
733
frontend/src/views/user/KeysView.vue
Normal file
@@ -0,0 +1,733 @@
|
||||
<template>
|
||||
<AppLayout>
|
||||
<div class="space-y-6">
|
||||
<!-- Page Header Actions -->
|
||||
<div class="flex justify-end">
|
||||
<button
|
||||
@click="showCreateModal = true"
|
||||
class="btn btn-primary"
|
||||
>
|
||||
<svg class="w-5 h-5 mr-2" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
|
||||
</svg>
|
||||
{{ t('keys.createKey') }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- API Keys Table -->
|
||||
<div class="card overflow-hidden">
|
||||
<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="p-1 rounded-lg hover:bg-gray-100 dark:hover:bg-dark-700 transition-colors"
|
||||
: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')"
|
||||
>
|
||||
<svg v-if="copiedKeyId === row.id" class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
<svg v-else class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round"
|
||||
d="M15.666 3.888A2.25 2.25 0 0013.5 2.25h-3c-1.03 0-1.9.693-2.166 1.638m7.332 0c.055.194.084.4.084.612v0a.75.75 0 01-.75.75H9a.75.75 0 01-.75-.75v0c0-.212.03-.418.084-.612m7.332 0c.646.049 1.288.11 1.927.184 1.1.128 1.907 1.077 1.907 2.185V19.5a2.25 2.25 0 01-2.25 2.25H6.75A2.25 2.25 0 014.5 19.5V6.257c0-1.108.806-2.057 1.907-2.185a48.208 48.208 0 011.927-.184" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #cell-name="{ value }">
|
||||
<span class="font-medium text-gray-900 dark:text-white">{{ value }}</span>
|
||||
</template>
|
||||
|
||||
<template #cell-group="{ row }">
|
||||
<div class="relative group/dropdown">
|
||||
<button
|
||||
:ref="(el) => setGroupButtonRef(row.id, el)"
|
||||
@click="openGroupSelector(row)"
|
||||
class="flex items-center gap-2 px-2 py-1 -mx-2 -my-1 rounded-lg hover:bg-gray-100 dark:hover:bg-dark-700 transition-all duration-200 cursor-pointer"
|
||||
:title="t('keys.clickToChangeGroup')"
|
||||
>
|
||||
<GroupBadge
|
||||
v-if="row.group"
|
||||
:name="row.group.name"
|
||||
: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="w-3.5 h-3.5 text-gray-400 opacity-0 group-hover/dropdown:opacity-100 transition-opacity" 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="flex items-center gap-1.5 mt-0.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>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #cell-status="{ value }">
|
||||
<span
|
||||
:class="[
|
||||
'badge',
|
||||
value === 'active'
|
||||
? 'badge-success'
|
||||
: 'badge-gray'
|
||||
]"
|
||||
>
|
||||
{{ value }}
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<template #cell-created_at="{ value }">
|
||||
<span class="text-sm text-gray-500 dark:text-dark-400">{{ formatDate(value) }}</span>
|
||||
</template>
|
||||
|
||||
<template #cell-actions="{ row }">
|
||||
<div class="flex items-center gap-1">
|
||||
<!-- Use Key Button -->
|
||||
<button
|
||||
@click="openUseKeyModal(row)"
|
||||
class="p-2 rounded-lg hover:bg-green-50 dark:hover:bg-green-900/20 text-gray-500 hover:text-green-600 dark:hover:text-green-400 transition-colors"
|
||||
:title="t('keys.useKey')"
|
||||
>
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M6.75 7.5l3 2.25-3 2.25m4.5 0h3m-9 8.25h13.5A2.25 2.25 0 0021 18V6a2.25 2.25 0 00-2.25-2.25H5.25A2.25 2.25 0 003 6v12a2.25 2.25 0 002.25 2.25z" />
|
||||
</svg>
|
||||
</button>
|
||||
<!-- Import to CC Switch Button -->
|
||||
<button
|
||||
@click="importToCcswitch(row.key)"
|
||||
class="p-2 rounded-lg hover:bg-blue-50 dark:hover:bg-blue-900/20 text-gray-500 hover:text-blue-600 dark:hover:text-blue-400 transition-colors"
|
||||
:title="t('keys.importToCcSwitch')"
|
||||
>
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5m-13.5-9L12 3m0 0l4.5 4.5M12 3v13.5" />
|
||||
</svg>
|
||||
</button>
|
||||
<!-- Toggle Status Button -->
|
||||
<button
|
||||
@click="toggleKeyStatus(row)"
|
||||
:class="[
|
||||
'p-2 rounded-lg transition-colors',
|
||||
row.status === 'active'
|
||||
? 'hover:bg-yellow-50 dark:hover:bg-yellow-900/20 text-gray-500 hover:text-yellow-600 dark:hover:text-yellow-400'
|
||||
: 'hover:bg-green-50 dark:hover:bg-green-900/20 text-gray-500 hover:text-green-600 dark:hover:text-green-400'
|
||||
]"
|
||||
:title="row.status === 'active' ? t('keys.disable') : t('keys.enable')"
|
||||
>
|
||||
<svg v-if="row.status === 'active'" class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M18.364 18.364A9 9 0 005.636 5.636m12.728 12.728A9 9 0 015.636 5.636m12.728 12.728L5.636 5.636" />
|
||||
</svg>
|
||||
<svg v-else class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M9 12.75L11.25 15 15 9.75M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
</button>
|
||||
<!-- Edit Button -->
|
||||
<button
|
||||
@click="editKey(row)"
|
||||
class="p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-dark-700 text-gray-500 hover:text-primary-600 dark:hover:text-primary-400 transition-colors"
|
||||
title="Edit"
|
||||
>
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round"
|
||||
d="M16.862 4.487l1.687-1.688a1.875 1.875 0 112.652 2.652L10.582 16.07a4.5 4.5 0 01-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 011.13-1.897l8.932-8.931zm0 0L19.5 7.125M18 14v4.75A2.25 2.25 0 0115.75 21H5.25A2.25 2.25 0 013 18.75V8.25A2.25 2.25 0 015.25 6H10" />
|
||||
</svg>
|
||||
</button>
|
||||
<!-- Delete Button -->
|
||||
<button
|
||||
@click="confirmDelete(row)"
|
||||
class="p-2 rounded-lg hover:bg-red-50 dark:hover:bg-red-900/20 text-gray-500 hover:text-red-600 dark:hover:text-red-400 transition-colors"
|
||||
title="Delete"
|
||||
>
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round"
|
||||
d="M14.74 9l-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 01-2.244 2.077H8.084a2.25 2.25 0 01-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 00-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 013.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 00-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 00-7.5 0" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #empty>
|
||||
<EmptyState
|
||||
:title="t('keys.noKeysYet')"
|
||||
:description="t('keys.createFirstKey')"
|
||||
:action-text="t('keys.createKey')"
|
||||
@action="showCreateModal = true"
|
||||
/>
|
||||
</template>
|
||||
</DataTable>
|
||||
</div>
|
||||
|
||||
<!-- Pagination -->
|
||||
<Pagination
|
||||
v-if="pagination.total > 0"
|
||||
:page="pagination.page"
|
||||
:total="pagination.total"
|
||||
:page-size="pagination.page_size"
|
||||
@update:page="handlePageChange"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Create/Edit Modal -->
|
||||
<Modal
|
||||
:show="showCreateModal || showEditModal"
|
||||
:title="showEditModal ? t('keys.editKey') : t('keys.createKey')"
|
||||
@close="closeModals"
|
||||
>
|
||||
<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')"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="input-label">{{ t('keys.groupLabel') }}</label>
|
||||
<Select
|
||||
v-model="formData.group_id"
|
||||
:options="groupOptions"
|
||||
:placeholder="t('keys.selectGroup')"
|
||||
>
|
||||
<template #selected="{ option }">
|
||||
<GroupBadge
|
||||
v-if="option"
|
||||
:name="option.label"
|
||||
:subscription-type="option.subscriptionType"
|
||||
:rate-multiplier="option.rate"
|
||||
/>
|
||||
<span v-else class="text-gray-400">{{ t('keys.selectGroup') }}</span>
|
||||
</template>
|
||||
<template #option="{ option }">
|
||||
<GroupBadge
|
||||
:name="option.label"
|
||||
:subscription-type="option.subscriptionType"
|
||||
:rate-multiplier="option.rate"
|
||||
/>
|
||||
</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>
|
||||
|
||||
<div class="flex justify-end gap-3 pt-4">
|
||||
<button
|
||||
@click="closeModals"
|
||||
type="button"
|
||||
class="btn btn-secondary"
|
||||
>
|
||||
{{ t('common.cancel') }}
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
:disabled="submitting"
|
||||
class="btn btn-primary"
|
||||
>
|
||||
<svg
|
||||
v-if="submitting"
|
||||
class="animate-spin -ml-1 mr-2 h-4 w-4"
|
||||
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>
|
||||
</form>
|
||||
</Modal>
|
||||
|
||||
<!-- 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"
|
||||
/>
|
||||
|
||||
<!-- Use Key Modal -->
|
||||
<UseKeyModal
|
||||
:show="showUseKeyModal"
|
||||
:api-key="selectedKey?.key || ''"
|
||||
:base-url="publicSettings?.api_base_url || ''"
|
||||
@close="closeUseKeyModal"
|
||||
/>
|
||||
|
||||
<!-- Group Selector Dropdown (Teleported to body to avoid overflow clipping) -->
|
||||
<Teleport to="body">
|
||||
<div
|
||||
v-if="groupSelectorKeyId !== null && dropdownPosition"
|
||||
ref="dropdownRef"
|
||||
class="fixed z-[9999] w-64 rounded-xl bg-white dark:bg-dark-800 shadow-lg ring-1 ring-black/5 dark:ring-white/10 overflow-hidden animate-in fade-in slide-in-from-top-2 duration-200"
|
||||
:style="{ top: dropdownPosition.top + 'px', left: dropdownPosition.left + 'px' }"
|
||||
>
|
||||
<div class="p-1.5 max-h-64 overflow-y-auto">
|
||||
<button
|
||||
v-for="option in groupOptions"
|
||||
:key="option.value ?? 'null'"
|
||||
@click="changeGroup(selectedKeyForGroup!, option.value)"
|
||||
:class="[
|
||||
'w-full flex items-center justify-between px-3 py-2 rounded-lg 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'
|
||||
]"
|
||||
>
|
||||
<GroupBadge
|
||||
:name="option.label"
|
||||
:subscription-type="option.subscriptionType"
|
||||
:rate-multiplier="option.rate"
|
||||
/>
|
||||
<svg
|
||||
v-if="selectedKeyForGroup?.group_id === option.value || (!selectedKeyForGroup?.group_id && option.value === null)"
|
||||
class="w-4 h-4 text-primary-600 dark:text-primary-400 shrink-0"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width="2"
|
||||
>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
</AppLayout>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
|
||||
const { t } = useI18n()
|
||||
import { keysAPI, authAPI, usageAPI, userGroupsAPI } from '@/api'
|
||||
import AppLayout from '@/components/layout/AppLayout.vue'
|
||||
import DataTable from '@/components/common/DataTable.vue'
|
||||
import Pagination from '@/components/common/Pagination.vue'
|
||||
import Modal from '@/components/common/Modal.vue'
|
||||
import ConfirmDialog from '@/components/common/ConfirmDialog.vue'
|
||||
import EmptyState from '@/components/common/EmptyState.vue'
|
||||
import Select from '@/components/common/Select.vue'
|
||||
import UseKeyModal from '@/components/keys/UseKeyModal.vue'
|
||||
import GroupBadge from '@/components/common/GroupBadge.vue'
|
||||
import type { ApiKey, Group, PublicSettings, SubscriptionType } from '@/types'
|
||||
import type { Column } from '@/components/common/DataTable.vue'
|
||||
import type { BatchApiKeyUsageStats } from '@/api/usage'
|
||||
|
||||
const appStore = useAppStore()
|
||||
|
||||
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: '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 showUseKeyModal = ref(false)
|
||||
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())
|
||||
|
||||
// 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: HTMLElement | null) => {
|
||||
if (el) {
|
||||
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: ''
|
||||
})
|
||||
|
||||
// 自定义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,
|
||||
rate: group.rate_multiplier,
|
||||
subscriptionType: group.subscription_type
|
||||
}))
|
||||
)
|
||||
|
||||
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) => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text)
|
||||
copiedKeyId.value = keyId
|
||||
setTimeout(() => {
|
||||
copiedKeyId.value = null
|
||||
}, 2000)
|
||||
} catch (error) {
|
||||
appStore.showError(t('common.copyFailed'))
|
||||
}
|
||||
}
|
||||
|
||||
const formatDate = (dateString: string): string => {
|
||||
const date = new Date(dateString)
|
||||
return date.toLocaleDateString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric'
|
||||
})
|
||||
}
|
||||
|
||||
const loadApiKeys = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const response = await keysAPI.list(pagination.value.page, pagination.value.page_size)
|
||||
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)
|
||||
usageStats.value = usageResponse.stats
|
||||
} catch (e) {
|
||||
console.error('Failed to load usage stats:', e)
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
appStore.showError(t('keys.failedToLoad'))
|
||||
} finally {
|
||||
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 editKey = (key: ApiKey) => {
|
||||
selectedKey.value = key
|
||||
formData.value = {
|
||||
name: key.name,
|
||||
group_id: key.group_id,
|
||||
status: key.status
|
||||
}
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
submitting.value = true
|
||||
try {
|
||||
if (showEditModal.value && selectedKey.value) {
|
||||
await keysAPI.update(selectedKey.value.id, formData.value)
|
||||
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)
|
||||
appStore.showSuccess(t('keys.keyCreatedSuccess'))
|
||||
}
|
||||
closeModals()
|
||||
loadApiKeys()
|
||||
} catch (error: any) {
|
||||
const errorMsg = error.response?.data?.detail || t('keys.failedToSave')
|
||||
appStore.showError(errorMsg)
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
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) {
|
||||
appStore.showError(t('keys.failedToDelete'))
|
||||
}
|
||||
}
|
||||
|
||||
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: ''
|
||||
}
|
||||
}
|
||||
|
||||
const importToCcswitch = (apiKey: string) => {
|
||||
const baseUrl = publicSettings.value?.api_base_url || window.location.origin
|
||||
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: 'claude',
|
||||
name: 'sub2api',
|
||||
homepage: baseUrl,
|
||||
endpoint: baseUrl,
|
||||
apiKey: apiKey,
|
||||
configFormat: 'json',
|
||||
usageEnabled: 'true',
|
||||
usageScript: btoa(usageScript),
|
||||
usageAutoInterval: '30'
|
||||
})
|
||||
const deeplink = `ccswitch://v1/import?${params.toString()}`
|
||||
window.open(deeplink, '_self')
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadApiKeys()
|
||||
loadGroups()
|
||||
loadPublicSettings()
|
||||
document.addEventListener('click', closeGroupSelector)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
document.removeEventListener('click', closeGroupSelector)
|
||||
})
|
||||
</script>
|
||||
Reference in New Issue
Block a user