Merge branch 'main' into feature/antigravity_auth
This commit is contained in:
@@ -165,7 +165,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DataTable :columns="columns" :data="accounts" :loading="loading" :actions-count="6">
|
||||
<DataTable :columns="columns" :data="accounts" :loading="loading">
|
||||
<template #cell-select="{ row }">
|
||||
<input
|
||||
type="checkbox"
|
||||
@@ -275,9 +275,9 @@
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<template #cell-actions="{ row, expanded }">
|
||||
<template #cell-actions="{ row }">
|
||||
<div class="flex items-center gap-1">
|
||||
<!-- 主要操作:编辑和删除(始终显示) -->
|
||||
<!-- Edit Button -->
|
||||
<button
|
||||
@click="handleEdit(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"
|
||||
@@ -297,6 +297,8 @@
|
||||
</svg>
|
||||
<span class="text-xs">{{ t('common.edit') }}</span>
|
||||
</button>
|
||||
|
||||
<!-- Delete Button -->
|
||||
<button
|
||||
@click="handleDelete(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"
|
||||
@@ -317,131 +319,28 @@
|
||||
<span class="text-xs">{{ t('common.delete') }}</span>
|
||||
</button>
|
||||
|
||||
<!-- 次要操作:展开时显示 -->
|
||||
<template v-if="expanded">
|
||||
<!-- Reset Status button for error accounts -->
|
||||
<button
|
||||
v-if="row.status === 'error'"
|
||||
@click="handleResetStatus(row)"
|
||||
class="flex flex-col items-center gap-0.5 rounded-lg p-1.5 text-red-500 transition-colors hover:bg-red-50 hover:text-red-600 dark:hover:bg-red-900/20 dark:hover:text-red-400"
|
||||
>
|
||||
<!-- More Actions Menu Trigger -->
|
||||
<button
|
||||
:ref="(el) => setActionButtonRef(row.id, el)"
|
||||
@click="openActionMenu(row)"
|
||||
class="action-menu-trigger flex flex-col items-center gap-0.5 rounded-lg p-1.5 text-gray-500 transition-colors hover:bg-gray-100 hover:text-gray-900 dark:hover:bg-dark-700 dark:hover:text-white"
|
||||
:class="{ 'bg-gray-100 text-gray-900 dark:bg-dark-700 dark:text-white': activeMenuId === row.id }"
|
||||
>
|
||||
<svg
|
||||
class="h-4 w-4"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M9 15L3 9m0 0l6-6M3 9h12a6 6 0 010 12h-3"
|
||||
d="M6.75 12a.75.75 0 11-1.5 0 .75.75 0 011.5 0zM12.75 12a.75.75 0 11-1.5 0 .75.75 0 011.5 0zM18.75 12a.75.75 0 11-1.5 0 .75.75 0 011.5 0z"
|
||||
/>
|
||||
</svg>
|
||||
<span class="text-xs">{{ t('admin.accounts.resetStatus') }}</span>
|
||||
</button>
|
||||
<!-- Clear Rate Limit button -->
|
||||
<button
|
||||
v-if="isRateLimited(row) || isOverloaded(row)"
|
||||
@click="handleClearRateLimit(row)"
|
||||
class="flex flex-col items-center gap-0.5 rounded-lg p-1.5 text-amber-500 transition-colors hover:bg-amber-50 hover:text-amber-600 dark:hover:bg-amber-900/20 dark:hover:text-amber-400"
|
||||
>
|
||||
<svg
|
||||
class="h-4 w-4"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width="1.5"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M12 6v6h4.5m4.5 0a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
<span class="text-xs">{{ t('admin.accounts.clearRateLimit') }}</span>
|
||||
</button>
|
||||
<!-- Test Connection button -->
|
||||
<button
|
||||
@click="handleTest(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"
|
||||
>
|
||||
<svg
|
||||
class="h-4 w-4"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width="1.5"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M5.25 5.653c0-.856.917-1.398 1.667-.986l11.54 6.347a1.125 1.125 0 010 1.972l-11.54 6.347a1.125 1.125 0 01-1.667-.986V5.653z"
|
||||
/>
|
||||
</svg>
|
||||
<span class="text-xs">{{ t('admin.accounts.testConnection') }}</span>
|
||||
</button>
|
||||
<!-- View Stats button -->
|
||||
<button
|
||||
@click="handleViewStats(row)"
|
||||
class="flex flex-col items-center gap-0.5 rounded-lg p-1.5 text-gray-500 transition-colors hover:bg-indigo-50 hover:text-indigo-600 dark:hover:bg-indigo-900/20 dark:hover:text-indigo-400"
|
||||
>
|
||||
<svg
|
||||
class="h-4 w-4"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width="1.5"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M3 13.125C3 12.504 3.504 12 4.125 12h2.25c.621 0 1.125.504 1.125 1.125v6.75C7.5 20.496 6.996 21 6.375 21h-2.25A1.125 1.125 0 013 19.875v-6.75zM9.75 8.625c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125v11.25c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 01-1.125-1.125V8.625zM16.5 4.125c0-.621.504-1.125 1.125-1.125h2.25C20.496 3 21 3.504 21 4.125v15.75c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 01-1.125-1.125V4.125z"
|
||||
/>
|
||||
</svg>
|
||||
<span class="text-xs">{{ t('admin.accounts.viewStats') }}</span>
|
||||
</button>
|
||||
<button
|
||||
v-if="row.type === 'oauth' || row.type === 'setup-token'"
|
||||
@click="handleReAuth(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"
|
||||
>
|
||||
<svg
|
||||
class="h-4 w-4"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width="1.5"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M13.19 8.688a4.5 4.5 0 011.242 7.244l-4.5 4.5a4.5 4.5 0 01-6.364-6.364l1.757-1.757m13.35-.622l1.757-1.757a4.5 4.5 0 00-6.364-6.364l-4.5 4.5a4.5 4.5 0 001.242 7.244"
|
||||
/>
|
||||
</svg>
|
||||
<span class="text-xs">{{ t('admin.accounts.reAuthorize') }}</span>
|
||||
</button>
|
||||
<button
|
||||
v-if="row.type === 'oauth' || row.type === 'setup-token'"
|
||||
@click="handleRefreshToken(row)"
|
||||
class="flex flex-col items-center gap-0.5 rounded-lg p-1.5 text-gray-500 transition-colors hover:bg-purple-50 hover:text-purple-600 dark:hover:bg-purple-900/20 dark:hover:text-purple-400"
|
||||
>
|
||||
<svg
|
||||
class="h-4 w-4"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width="1.5"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0l3.181 3.183a8.25 8.25 0 0013.803-3.7M4.031 9.865a8.25 8.25 0 0113.803-3.7l3.181 3.182m0-4.991v4.99"
|
||||
/>
|
||||
</svg>
|
||||
<span class="text-xs">{{ t('admin.accounts.refreshToken') }}</span>
|
||||
</button>
|
||||
</template>
|
||||
<span class="text-xs">{{ t('common.more') }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -463,6 +362,7 @@
|
||||
:total="pagination.total"
|
||||
:page-size="pagination.page_size"
|
||||
@update:page="handlePageChange"
|
||||
@update:pageSize="handlePageSizeChange"
|
||||
/>
|
||||
</template>
|
||||
</TablePageLayout>
|
||||
@@ -537,11 +437,61 @@
|
||||
@close="showBulkEditModal = false"
|
||||
@updated="handleBulkUpdated"
|
||||
/>
|
||||
<!-- Action Menu (Teleported) -->
|
||||
<Teleport to="body">
|
||||
<div
|
||||
v-if="activeMenuId !== null && menuPosition"
|
||||
class="action-menu-content fixed z-[9999] w-52 overflow-hidden rounded-xl bg-white shadow-lg ring-1 ring-black/5 dark:bg-dark-800 dark:ring-white/10"
|
||||
:style="{ top: menuPosition.top + 'px', left: menuPosition.left + 'px' }"
|
||||
>
|
||||
<div class="py-1">
|
||||
<template v-for="account in accounts" :key="account.id">
|
||||
<template v-if="account.id === activeMenuId">
|
||||
<button
|
||||
@click="handleTest(account); closeActionMenu()"
|
||||
class="flex w-full items-center gap-2 px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-dark-700"
|
||||
>
|
||||
<svg class="h-4 w-4 text-green-500" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z" /><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z" /></svg>
|
||||
{{ t('admin.accounts.testConnection') }}
|
||||
</button>
|
||||
<button
|
||||
@click="handleViewStats(account); closeActionMenu()"
|
||||
class="flex w-full items-center gap-2 px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-dark-700"
|
||||
>
|
||||
<svg class="h-4 w-4 text-indigo-500" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" /></svg>
|
||||
{{ t('admin.accounts.viewStats') }}
|
||||
</button>
|
||||
<template v-if="account.type === 'oauth' || account.type === 'setup-token'">
|
||||
<button @click="handleReAuth(account); closeActionMenu()" class="flex w-full items-center gap-2 px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-dark-700">
|
||||
<svg class="h-4 w-4 text-blue-500" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1" /></svg>
|
||||
{{ t('admin.accounts.reAuthorize') }}
|
||||
</button>
|
||||
<button @click="handleRefreshToken(account); closeActionMenu()" class="flex w-full items-center gap-2 px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-dark-700">
|
||||
<svg class="h-4 w-4 text-purple-500" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h5M20 20v-5h-5M4 4l16 16" /></svg>
|
||||
{{ t('admin.accounts.refreshToken') }}
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<div v-if="account.status === 'error' || isRateLimited(account) || isOverloaded(account)" class="my-1 border-t border-gray-100 dark:border-dark-700"></div>
|
||||
|
||||
<button v-if="account.status === 'error'" @click="handleResetStatus(account); closeActionMenu()" class="flex w-full items-center gap-2 px-4 py-2 text-sm text-yellow-600 hover:bg-gray-100 dark:text-yellow-400 dark:hover:bg-dark-700">
|
||||
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z" /><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z" /></svg>
|
||||
{{ t('admin.accounts.resetStatus') }}
|
||||
</button>
|
||||
<button v-if="isRateLimited(account) || isOverloaded(account)" @click="handleClearRateLimit(account); closeActionMenu()" class="flex w-full items-center gap-2 px-4 py-2 text-sm text-amber-600 hover:bg-gray-100 dark:text-amber-400 dark:hover:bg-dark-700">
|
||||
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" /></svg>
|
||||
{{ t('admin.accounts.clearRateLimit') }}
|
||||
</button>
|
||||
</template>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
</AppLayout>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, computed, onMounted } from 'vue'
|
||||
import { ref, reactive, computed, onMounted, onUnmounted, type ComponentPublicInstance } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
import { adminAPI } from '@/api/admin'
|
||||
@@ -629,6 +579,7 @@ const pagination = reactive({
|
||||
total: 0,
|
||||
pages: 0
|
||||
})
|
||||
let abortController: AbortController | null = null
|
||||
|
||||
// Modal states
|
||||
const showCreateModal = ref(false)
|
||||
@@ -648,6 +599,49 @@ const statsAccount = ref<Account | null>(null)
|
||||
const togglingSchedulable = ref<number | null>(null)
|
||||
const bulkDeleting = ref(false)
|
||||
|
||||
// Action Menu State
|
||||
const activeMenuId = ref<number | null>(null)
|
||||
const menuPosition = ref<{ top: number; left: number } | null>(null)
|
||||
const actionButtonRefs = ref<Map<number, HTMLElement>>(new Map())
|
||||
|
||||
const setActionButtonRef = (accountId: number, el: Element | ComponentPublicInstance | null) => {
|
||||
if (el instanceof HTMLElement) {
|
||||
actionButtonRefs.value.set(accountId, el)
|
||||
} else {
|
||||
actionButtonRefs.value.delete(accountId)
|
||||
}
|
||||
}
|
||||
|
||||
const openActionMenu = (account: Account) => {
|
||||
if (activeMenuId.value === account.id) {
|
||||
closeActionMenu()
|
||||
} else {
|
||||
const buttonEl = actionButtonRefs.value.get(account.id)
|
||||
if (buttonEl) {
|
||||
const rect = buttonEl.getBoundingClientRect()
|
||||
// Position menu to the left of the button, slightly below
|
||||
menuPosition.value = {
|
||||
top: rect.bottom + 4,
|
||||
left: rect.right - 208 // w-52 is 208px
|
||||
}
|
||||
}
|
||||
activeMenuId.value = account.id
|
||||
}
|
||||
}
|
||||
|
||||
const closeActionMenu = () => {
|
||||
activeMenuId.value = null
|
||||
menuPosition.value = null
|
||||
}
|
||||
|
||||
// Close menu when clicking outside
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
const target = event.target as HTMLElement
|
||||
if (!target.closest('.action-menu-trigger') && !target.closest('.action-menu-content')) {
|
||||
closeActionMenu()
|
||||
}
|
||||
}
|
||||
|
||||
// Bulk selection
|
||||
const selectedAccountIds = ref<number[]>([])
|
||||
const selectCurrentPageAccounts = () => {
|
||||
@@ -669,6 +663,9 @@ const isOverloaded = (account: Account): boolean => {
|
||||
|
||||
// Data loading
|
||||
const loadAccounts = async () => {
|
||||
abortController?.abort()
|
||||
const currentAbortController = new AbortController()
|
||||
abortController = currentAbortController
|
||||
loading.value = true
|
||||
try {
|
||||
const response = await adminAPI.accounts.list(pagination.page, pagination.page_size, {
|
||||
@@ -676,15 +673,24 @@ const loadAccounts = async () => {
|
||||
type: filters.type || undefined,
|
||||
status: filters.status || undefined,
|
||||
search: searchQuery.value || undefined
|
||||
}, {
|
||||
signal: currentAbortController.signal
|
||||
})
|
||||
if (currentAbortController.signal.aborted) return
|
||||
accounts.value = response.items
|
||||
pagination.total = response.total
|
||||
pagination.pages = response.pages
|
||||
} catch (error) {
|
||||
const errorInfo = error as { name?: string; code?: string }
|
||||
if (errorInfo?.name === 'AbortError' || errorInfo?.name === 'CanceledError' || errorInfo?.code === 'ERR_CANCELED') {
|
||||
return
|
||||
}
|
||||
appStore.showError(t('admin.accounts.failedToLoad'))
|
||||
console.error('Error loading accounts:', error)
|
||||
} finally {
|
||||
loading.value = false
|
||||
if (abortController === currentAbortController) {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -721,6 +727,12 @@ const handlePageChange = (page: number) => {
|
||||
loadAccounts()
|
||||
}
|
||||
|
||||
const handlePageSizeChange = (pageSize: number) => {
|
||||
pagination.page_size = pageSize
|
||||
pagination.page = 1
|
||||
loadAccounts()
|
||||
}
|
||||
|
||||
const handleCrsSynced = () => {
|
||||
showCrsSyncModal.value = false
|
||||
loadAccounts()
|
||||
@@ -910,5 +922,12 @@ onMounted(() => {
|
||||
loadAccounts()
|
||||
loadProxies()
|
||||
loadGroups()
|
||||
document.addEventListener('click', handleClickOutside)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
abortController?.abort()
|
||||
abortController = null
|
||||
document.removeEventListener('click', handleClickOutside)
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -223,18 +223,19 @@
|
||||
:total="pagination.total"
|
||||
:page-size="pagination.page_size"
|
||||
@update:page="handlePageChange"
|
||||
@update:pageSize="handlePageSizeChange"
|
||||
/>
|
||||
</template>
|
||||
</TablePageLayout>
|
||||
|
||||
<!-- Create Group Modal -->
|
||||
<Modal
|
||||
<BaseDialog
|
||||
:show="showCreateModal"
|
||||
:title="t('admin.groups.createGroup')"
|
||||
size="lg"
|
||||
width="normal"
|
||||
@close="closeCreateModal"
|
||||
>
|
||||
<form @submit.prevent="handleCreateGroup" class="space-y-5">
|
||||
<form id="create-group-form" @submit.prevent="handleCreateGroup" class="space-y-5">
|
||||
<div>
|
||||
<label class="input-label">{{ t('admin.groups.form.name') }}</label>
|
||||
<input
|
||||
@@ -345,11 +346,19 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</form>
|
||||
|
||||
<template #footer>
|
||||
<div class="flex justify-end gap-3 pt-4">
|
||||
<button @click="closeCreateModal" type="button" class="btn btn-secondary">
|
||||
{{ t('common.cancel') }}
|
||||
</button>
|
||||
<button type="submit" :disabled="submitting" class="btn btn-primary">
|
||||
<button
|
||||
type="submit"
|
||||
form="create-group-form"
|
||||
:disabled="submitting"
|
||||
class="btn btn-primary"
|
||||
>
|
||||
<svg
|
||||
v-if="submitting"
|
||||
class="-ml-1 mr-2 h-4 w-4 animate-spin"
|
||||
@@ -373,17 +382,22 @@
|
||||
{{ submitting ? t('admin.groups.creating') : t('common.create') }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</Modal>
|
||||
</template>
|
||||
</BaseDialog>
|
||||
|
||||
<!-- Edit Group Modal -->
|
||||
<Modal
|
||||
<BaseDialog
|
||||
:show="showEditModal"
|
||||
:title="t('admin.groups.editGroup')"
|
||||
size="lg"
|
||||
width="normal"
|
||||
@close="closeEditModal"
|
||||
>
|
||||
<form v-if="editingGroup" @submit.prevent="handleUpdateGroup" class="space-y-5">
|
||||
<form
|
||||
v-if="editingGroup"
|
||||
id="edit-group-form"
|
||||
@submit.prevent="handleUpdateGroup"
|
||||
class="space-y-5"
|
||||
>
|
||||
<div>
|
||||
<label class="input-label">{{ t('admin.groups.form.name') }}</label>
|
||||
<input v-model="editForm.name" type="text" required class="input" />
|
||||
@@ -490,11 +504,19 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</form>
|
||||
|
||||
<template #footer>
|
||||
<div class="flex justify-end gap-3 pt-4">
|
||||
<button @click="closeEditModal" type="button" class="btn btn-secondary">
|
||||
{{ t('common.cancel') }}
|
||||
</button>
|
||||
<button type="submit" :disabled="submitting" class="btn btn-primary">
|
||||
<button
|
||||
type="submit"
|
||||
form="edit-group-form"
|
||||
:disabled="submitting"
|
||||
class="btn btn-primary"
|
||||
>
|
||||
<svg
|
||||
v-if="submitting"
|
||||
class="-ml-1 mr-2 h-4 w-4 animate-spin"
|
||||
@@ -518,8 +540,8 @@
|
||||
{{ submitting ? t('admin.groups.updating') : t('common.update') }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</Modal>
|
||||
</template>
|
||||
</BaseDialog>
|
||||
|
||||
<!-- Delete Confirmation Dialog -->
|
||||
<ConfirmDialog
|
||||
@@ -546,7 +568,7 @@ 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 Modal from '@/components/common/Modal.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'
|
||||
@@ -616,6 +638,8 @@ const pagination = reactive({
|
||||
pages: 0
|
||||
})
|
||||
|
||||
let abortController: AbortController | null = null
|
||||
|
||||
const showCreateModal = ref(false)
|
||||
const showEditModal = ref(false)
|
||||
const showDeleteDialog = ref(false)
|
||||
@@ -660,21 +684,33 @@ const deleteConfirmMessage = computed(() => {
|
||||
})
|
||||
|
||||
const loadGroups = async () => {
|
||||
if (abortController) {
|
||||
abortController.abort()
|
||||
}
|
||||
const currentController = new AbortController()
|
||||
abortController = currentController
|
||||
const { signal } = currentController
|
||||
loading.value = true
|
||||
try {
|
||||
const response = await adminAPI.groups.list(pagination.page, pagination.page_size, {
|
||||
platform: (filters.platform as GroupPlatform) || undefined,
|
||||
status: filters.status as any,
|
||||
is_exclusive: filters.is_exclusive ? filters.is_exclusive === 'true' : undefined
|
||||
})
|
||||
}, { signal })
|
||||
if (signal.aborted) return
|
||||
groups.value = response.items
|
||||
pagination.total = response.total
|
||||
pagination.pages = response.pages
|
||||
} catch (error) {
|
||||
} catch (error: any) {
|
||||
if (signal.aborted || error?.name === 'AbortError' || error?.code === 'ERR_CANCELED') {
|
||||
return
|
||||
}
|
||||
appStore.showError(t('admin.groups.failedToLoad'))
|
||||
console.error('Error loading groups:', error)
|
||||
} finally {
|
||||
loading.value = false
|
||||
if (abortController === currentController && !signal.aborted) {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -683,6 +719,12 @@ const handlePageChange = (page: number) => {
|
||||
loadGroups()
|
||||
}
|
||||
|
||||
const handlePageSizeChange = (pageSize: number) => {
|
||||
pagination.page_size = pageSize
|
||||
pagination.page = 1
|
||||
loadGroups()
|
||||
}
|
||||
|
||||
const closeCreateModal = () => {
|
||||
showCreateModal.value = false
|
||||
createForm.name = ''
|
||||
|
||||
@@ -209,15 +209,16 @@
|
||||
:total="pagination.total"
|
||||
:page-size="pagination.page_size"
|
||||
@update:page="handlePageChange"
|
||||
@update:pageSize="handlePageSizeChange"
|
||||
/>
|
||||
</template>
|
||||
</TablePageLayout>
|
||||
|
||||
<!-- Create Proxy Modal -->
|
||||
<Modal
|
||||
<BaseDialog
|
||||
:show="showCreateModal"
|
||||
:title="t('admin.proxies.createProxy')"
|
||||
size="lg"
|
||||
width="normal"
|
||||
@close="closeCreateModal"
|
||||
>
|
||||
<!-- Tab Switch -->
|
||||
@@ -271,7 +272,12 @@
|
||||
</div>
|
||||
|
||||
<!-- Standard Add Form -->
|
||||
<form v-if="createMode === 'standard'" @submit.prevent="handleCreateProxy" class="space-y-5">
|
||||
<form
|
||||
v-if="createMode === 'standard'"
|
||||
id="create-proxy-form"
|
||||
@submit.prevent="handleCreateProxy"
|
||||
class="space-y-5"
|
||||
>
|
||||
<div>
|
||||
<label class="input-label">{{ t('admin.proxies.name') }}</label>
|
||||
<input
|
||||
@@ -329,34 +335,6 @@
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end gap-3 pt-4">
|
||||
<button @click="closeCreateModal" type="button" class="btn btn-secondary">
|
||||
{{ t('common.cancel') }}
|
||||
</button>
|
||||
<button type="submit" :disabled="submitting" class="btn btn-primary">
|
||||
<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('admin.proxies.creating') : t('common.create') }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<!-- Batch Add Form -->
|
||||
@@ -435,11 +413,44 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end gap-3 pt-4">
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<div class="flex justify-end gap-3">
|
||||
<button @click="closeCreateModal" type="button" class="btn btn-secondary">
|
||||
{{ t('common.cancel') }}
|
||||
</button>
|
||||
<button
|
||||
v-if="createMode === 'standard'"
|
||||
type="submit"
|
||||
form="create-proxy-form"
|
||||
:disabled="submitting"
|
||||
class="btn btn-primary"
|
||||
>
|
||||
<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('admin.proxies.creating') : t('common.create') }}
|
||||
</button>
|
||||
<button
|
||||
v-else
|
||||
@click="handleBatchCreate"
|
||||
type="button"
|
||||
:disabled="submitting || batchParseResult.valid === 0"
|
||||
@@ -472,17 +483,22 @@
|
||||
}}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
</template>
|
||||
</BaseDialog>
|
||||
|
||||
<!-- Edit Proxy Modal -->
|
||||
<Modal
|
||||
<BaseDialog
|
||||
:show="showEditModal"
|
||||
:title="t('admin.proxies.editProxy')"
|
||||
size="lg"
|
||||
width="normal"
|
||||
@close="closeEditModal"
|
||||
>
|
||||
<form v-if="editingProxy" @submit.prevent="handleUpdateProxy" class="space-y-5">
|
||||
<form
|
||||
v-if="editingProxy"
|
||||
id="edit-proxy-form"
|
||||
@submit.prevent="handleUpdateProxy"
|
||||
class="space-y-5"
|
||||
>
|
||||
<div>
|
||||
<label class="input-label">{{ t('admin.proxies.name') }}</label>
|
||||
<input v-model="editForm.name" type="text" required class="input" />
|
||||
@@ -526,11 +542,20 @@
|
||||
<Select v-model="editForm.status" :options="editStatusOptions" />
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end gap-3 pt-4">
|
||||
</form>
|
||||
|
||||
<template #footer>
|
||||
<div class="flex justify-end gap-3">
|
||||
<button @click="closeEditModal" type="button" class="btn btn-secondary">
|
||||
{{ t('common.cancel') }}
|
||||
</button>
|
||||
<button type="submit" :disabled="submitting" class="btn btn-primary">
|
||||
<button
|
||||
v-if="editingProxy"
|
||||
type="submit"
|
||||
form="edit-proxy-form"
|
||||
:disabled="submitting"
|
||||
class="btn btn-primary"
|
||||
>
|
||||
<svg
|
||||
v-if="submitting"
|
||||
class="-ml-1 mr-2 h-4 w-4 animate-spin"
|
||||
@@ -554,8 +579,8 @@
|
||||
{{ submitting ? t('admin.proxies.updating') : t('common.update') }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</Modal>
|
||||
</template>
|
||||
</BaseDialog>
|
||||
|
||||
<!-- Delete Confirmation Dialog -->
|
||||
<ConfirmDialog
|
||||
@@ -582,7 +607,7 @@ 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 Modal from '@/components/common/Modal.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'
|
||||
@@ -682,22 +707,44 @@ const editForm = reactive({
|
||||
status: 'active' as 'active' | 'inactive'
|
||||
})
|
||||
|
||||
let abortController: AbortController | null = null
|
||||
|
||||
const isAbortError = (error: unknown) => {
|
||||
if (!error || typeof error !== 'object') return false
|
||||
const maybeError = error as { name?: string; code?: string }
|
||||
return maybeError.name === 'AbortError' || maybeError.code === 'ERR_CANCELED'
|
||||
}
|
||||
|
||||
const loadProxies = async () => {
|
||||
if (abortController) {
|
||||
abortController.abort()
|
||||
}
|
||||
const currentAbortController = new AbortController()
|
||||
abortController = currentAbortController
|
||||
loading.value = true
|
||||
try {
|
||||
const response = await adminAPI.proxies.list(pagination.page, pagination.page_size, {
|
||||
protocol: filters.protocol || undefined,
|
||||
status: filters.status as any,
|
||||
search: searchQuery.value || undefined
|
||||
})
|
||||
}, { signal: currentAbortController.signal })
|
||||
if (currentAbortController.signal.aborted || abortController !== currentAbortController) {
|
||||
return
|
||||
}
|
||||
proxies.value = response.items
|
||||
pagination.total = response.total
|
||||
pagination.pages = response.pages
|
||||
} catch (error) {
|
||||
if (isAbortError(error)) {
|
||||
return
|
||||
}
|
||||
appStore.showError(t('admin.proxies.failedToLoad'))
|
||||
console.error('Error loading proxies:', error)
|
||||
} finally {
|
||||
loading.value = false
|
||||
if (abortController === currentAbortController) {
|
||||
loading.value = false
|
||||
abortController = null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -715,6 +762,12 @@ const handlePageChange = (page: number) => {
|
||||
loadProxies()
|
||||
}
|
||||
|
||||
const handlePageSizeChange = (pageSize: number) => {
|
||||
pagination.page_size = pageSize
|
||||
pagination.page = 1
|
||||
loadProxies()
|
||||
}
|
||||
|
||||
const closeCreateModal = () => {
|
||||
showCreateModal.value = false
|
||||
createMode.value = 'standard'
|
||||
|
||||
@@ -186,6 +186,7 @@
|
||||
:total="pagination.total"
|
||||
:page-size="pagination.page_size"
|
||||
@update:page="handlePageChange"
|
||||
@update:pageSize="handlePageSizeChange"
|
||||
/>
|
||||
|
||||
<!-- Batch Actions -->
|
||||
@@ -542,6 +543,8 @@ const pagination = reactive({
|
||||
pages: 0
|
||||
})
|
||||
|
||||
let abortController: AbortController | null = null
|
||||
|
||||
const showDeleteDialog = ref(false)
|
||||
const showDeleteUnusedDialog = ref(false)
|
||||
const deletingCode = ref<RedeemCode | null>(null)
|
||||
@@ -556,21 +559,46 @@ const generateForm = reactive({
|
||||
})
|
||||
|
||||
const loadCodes = async () => {
|
||||
if (abortController) {
|
||||
abortController.abort()
|
||||
}
|
||||
const currentController = new AbortController()
|
||||
abortController = currentController
|
||||
loading.value = true
|
||||
try {
|
||||
const response = await adminAPI.redeem.list(pagination.page, pagination.page_size, {
|
||||
type: filters.type as RedeemCodeType,
|
||||
status: filters.status as any,
|
||||
search: searchQuery.value || undefined
|
||||
})
|
||||
const response = await adminAPI.redeem.list(
|
||||
pagination.page,
|
||||
pagination.page_size,
|
||||
{
|
||||
type: filters.type as RedeemCodeType,
|
||||
status: filters.status as any,
|
||||
search: searchQuery.value || undefined
|
||||
},
|
||||
{
|
||||
signal: currentController.signal
|
||||
}
|
||||
)
|
||||
if (currentController.signal.aborted) {
|
||||
return
|
||||
}
|
||||
codes.value = response.items
|
||||
pagination.total = response.total
|
||||
pagination.pages = response.pages
|
||||
} catch (error) {
|
||||
} catch (error: any) {
|
||||
if (
|
||||
currentController.signal.aborted ||
|
||||
error?.name === 'AbortError' ||
|
||||
error?.code === 'ERR_CANCELED'
|
||||
) {
|
||||
return
|
||||
}
|
||||
appStore.showError(t('admin.redeem.failedToLoad'))
|
||||
console.error('Error loading redeem codes:', error)
|
||||
} finally {
|
||||
loading.value = false
|
||||
if (abortController === currentController && !currentController.signal.aborted) {
|
||||
loading.value = false
|
||||
abortController = null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -588,6 +616,12 @@ const handlePageChange = (page: number) => {
|
||||
loadCodes()
|
||||
}
|
||||
|
||||
const handlePageSizeChange = (pageSize: number) => {
|
||||
pagination.page_size = pageSize
|
||||
pagination.page = 1
|
||||
loadCodes()
|
||||
}
|
||||
|
||||
const handleGenerateCodes = async () => {
|
||||
// 订阅类型必须选择分组
|
||||
if (generateForm.type === 'subscription' && !generateForm.group_id) {
|
||||
|
||||
@@ -316,18 +316,23 @@
|
||||
:total="pagination.total"
|
||||
:page-size="pagination.page_size"
|
||||
@update:page="handlePageChange"
|
||||
@update:pageSize="handlePageSizeChange"
|
||||
/>
|
||||
</template>
|
||||
</TablePageLayout>
|
||||
|
||||
<!-- Assign Subscription Modal -->
|
||||
<Modal
|
||||
<BaseDialog
|
||||
:show="showAssignModal"
|
||||
:title="t('admin.subscriptions.assignSubscription')"
|
||||
size="lg"
|
||||
width="normal"
|
||||
@close="closeAssignModal"
|
||||
>
|
||||
<form @submit.prevent="handleAssignSubscription" class="space-y-5">
|
||||
<form
|
||||
id="assign-subscription-form"
|
||||
@submit.prevent="handleAssignSubscription"
|
||||
class="space-y-5"
|
||||
>
|
||||
<div>
|
||||
<label class="input-label">{{ t('admin.subscriptions.form.user') }}</label>
|
||||
<Select
|
||||
@@ -351,12 +356,18 @@
|
||||
<input v-model.number="assignForm.validity_days" type="number" min="1" class="input" />
|
||||
<p class="input-hint">{{ t('admin.subscriptions.validityHint') }}</p>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end gap-3 pt-4">
|
||||
</form>
|
||||
<template #footer>
|
||||
<div class="flex justify-end gap-3">
|
||||
<button @click="closeAssignModal" type="button" class="btn btn-secondary">
|
||||
{{ t('common.cancel') }}
|
||||
</button>
|
||||
<button type="submit" :disabled="submitting" class="btn btn-primary">
|
||||
<button
|
||||
type="submit"
|
||||
form="assign-subscription-form"
|
||||
:disabled="submitting"
|
||||
class="btn btn-primary"
|
||||
>
|
||||
<svg
|
||||
v-if="submitting"
|
||||
class="-ml-1 mr-2 h-4 w-4 animate-spin"
|
||||
@@ -380,18 +391,19 @@
|
||||
{{ submitting ? t('admin.subscriptions.assigning') : t('admin.subscriptions.assign') }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</Modal>
|
||||
</template>
|
||||
</BaseDialog>
|
||||
|
||||
<!-- Extend Subscription Modal -->
|
||||
<Modal
|
||||
<BaseDialog
|
||||
:show="showExtendModal"
|
||||
:title="t('admin.subscriptions.extendSubscription')"
|
||||
size="md"
|
||||
width="narrow"
|
||||
@close="closeExtendModal"
|
||||
>
|
||||
<form
|
||||
v-if="extendingSubscription"
|
||||
id="extend-subscription-form"
|
||||
@submit.prevent="handleExtendSubscription"
|
||||
class="space-y-5"
|
||||
>
|
||||
@@ -417,17 +429,23 @@
|
||||
<label class="input-label">{{ t('admin.subscriptions.form.extendDays') }}</label>
|
||||
<input v-model.number="extendForm.days" type="number" min="1" required class="input" />
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end gap-3 pt-4">
|
||||
</form>
|
||||
<template #footer>
|
||||
<div v-if="extendingSubscription" class="flex justify-end gap-3">
|
||||
<button @click="closeExtendModal" type="button" class="btn btn-secondary">
|
||||
{{ t('common.cancel') }}
|
||||
</button>
|
||||
<button type="submit" :disabled="submitting" class="btn btn-primary">
|
||||
<button
|
||||
type="submit"
|
||||
form="extend-subscription-form"
|
||||
:disabled="submitting"
|
||||
class="btn btn-primary"
|
||||
>
|
||||
{{ submitting ? t('admin.subscriptions.extending') : t('admin.subscriptions.extend') }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</Modal>
|
||||
</template>
|
||||
</BaseDialog>
|
||||
|
||||
<!-- Revoke Confirmation Dialog -->
|
||||
<ConfirmDialog
|
||||
@@ -455,7 +473,7 @@ 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 Modal from '@/components/common/Modal.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'
|
||||
@@ -485,6 +503,7 @@ const subscriptions = ref<UserSubscription[]>([])
|
||||
const groups = ref<Group[]>([])
|
||||
const users = ref<User[]>([])
|
||||
const loading = ref(false)
|
||||
let abortController: AbortController | null = null
|
||||
const filters = reactive({
|
||||
status: '',
|
||||
group_id: ''
|
||||
@@ -530,20 +549,36 @@ const subscriptionGroupOptions = computed(() =>
|
||||
const userOptions = computed(() => users.value.map((u) => ({ value: u.id, label: u.email })))
|
||||
|
||||
const loadSubscriptions = async () => {
|
||||
if (abortController) {
|
||||
abortController.abort()
|
||||
}
|
||||
const requestController = new AbortController()
|
||||
abortController = requestController
|
||||
const { signal } = requestController
|
||||
|
||||
loading.value = true
|
||||
try {
|
||||
const response = await adminAPI.subscriptions.list(pagination.page, pagination.page_size, {
|
||||
status: (filters.status as any) || undefined,
|
||||
group_id: filters.group_id ? parseInt(filters.group_id) : undefined
|
||||
}, {
|
||||
signal
|
||||
})
|
||||
if (signal.aborted || abortController !== requestController) return
|
||||
subscriptions.value = response.items
|
||||
pagination.total = response.total
|
||||
pagination.pages = response.pages
|
||||
} catch (error) {
|
||||
} catch (error: any) {
|
||||
if (signal.aborted || error?.name === 'AbortError' || error?.code === 'ERR_CANCELED') {
|
||||
return
|
||||
}
|
||||
appStore.showError(t('admin.subscriptions.failedToLoad'))
|
||||
console.error('Error loading subscriptions:', error)
|
||||
} finally {
|
||||
loading.value = false
|
||||
if (abortController === requestController) {
|
||||
loading.value = false
|
||||
abortController = null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -569,6 +604,12 @@ const handlePageChange = (page: number) => {
|
||||
loadSubscriptions()
|
||||
}
|
||||
|
||||
const handlePageSizeChange = (pageSize: number) => {
|
||||
pagination.page_size = pageSize
|
||||
pagination.page = 1
|
||||
loadSubscriptions()
|
||||
}
|
||||
|
||||
const closeAssignModal = () => {
|
||||
showAssignModal.value = false
|
||||
assignForm.user_id = null
|
||||
|
||||
@@ -224,7 +224,7 @@
|
||||
v-model="filters.api_key_id"
|
||||
:options="apiKeyOptions"
|
||||
:placeholder="t('usage.allApiKeys')"
|
||||
:disabled="!selectedUser && apiKeys.length === 0"
|
||||
searchable
|
||||
@change="applyFilters"
|
||||
/>
|
||||
</div>
|
||||
@@ -236,6 +236,7 @@
|
||||
v-model="filters.model"
|
||||
:options="modelOptions"
|
||||
:placeholder="t('admin.usage.allModels')"
|
||||
searchable
|
||||
@change="applyFilters"
|
||||
/>
|
||||
</div>
|
||||
@@ -534,6 +535,7 @@
|
||||
:total="pagination.total"
|
||||
:page-size="pagination.page_size"
|
||||
@update:page="handlePageChange"
|
||||
@update:pageSize="handlePageSizeChange"
|
||||
/>
|
||||
</div>
|
||||
</AppLayout>
|
||||
@@ -666,6 +668,7 @@ const models = ref<string[]>([])
|
||||
const accounts = ref<any[]>([])
|
||||
const groups = ref<any[]>([])
|
||||
const loading = ref(false)
|
||||
let abortController: AbortController | null = null
|
||||
|
||||
// User search state
|
||||
const userSearchKeyword = ref('')
|
||||
@@ -675,7 +678,7 @@ const showUserDropdown = ref(false)
|
||||
const selectedUser = ref<SimpleUser | null>(null)
|
||||
let searchTimeout: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
// API Key options computed from selected user's keys
|
||||
// API Key options computed from loaded keys
|
||||
const apiKeyOptions = computed(() => {
|
||||
return [
|
||||
{ value: null, label: t('usage.allApiKeys') },
|
||||
@@ -796,7 +799,7 @@ const selectUser = async (user: SimpleUser) => {
|
||||
filters.value.api_key_id = undefined
|
||||
|
||||
// Load API keys for selected user
|
||||
await loadApiKeysForUser(user.id)
|
||||
await loadApiKeys(user.id)
|
||||
applyFilters()
|
||||
}
|
||||
|
||||
@@ -807,10 +810,11 @@ const clearUserFilter = () => {
|
||||
filters.value.user_id = undefined
|
||||
filters.value.api_key_id = undefined
|
||||
apiKeys.value = []
|
||||
loadApiKeys()
|
||||
applyFilters()
|
||||
}
|
||||
|
||||
const loadApiKeysForUser = async (userId: number) => {
|
||||
const loadApiKeys = async (userId?: number) => {
|
||||
try {
|
||||
apiKeys.value = await adminAPI.usage.searchApiKeys(userId)
|
||||
} catch (error) {
|
||||
@@ -863,7 +867,24 @@ const formatCacheTokens = (value: number): string => {
|
||||
return value.toLocaleString()
|
||||
}
|
||||
|
||||
const isAbortError = (error: unknown): boolean => {
|
||||
if (error instanceof DOMException && error.name === 'AbortError') {
|
||||
return true
|
||||
}
|
||||
if (typeof error === 'object' && error !== null) {
|
||||
const maybeError = error as { code?: string; name?: string }
|
||||
return maybeError.code === 'ERR_CANCELED' || maybeError.name === 'CanceledError'
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
const loadUsageLogs = async () => {
|
||||
if (abortController) {
|
||||
abortController.abort()
|
||||
}
|
||||
const controller = new AbortController()
|
||||
abortController = controller
|
||||
const { signal } = controller
|
||||
loading.value = true
|
||||
try {
|
||||
const params: AdminUsageQueryParams = {
|
||||
@@ -872,17 +893,23 @@ const loadUsageLogs = async () => {
|
||||
...filters.value
|
||||
}
|
||||
|
||||
const response = await adminAPI.usage.list(params)
|
||||
const response = await adminAPI.usage.list(params, { signal })
|
||||
if (signal.aborted) {
|
||||
return
|
||||
}
|
||||
usageLogs.value = response.items
|
||||
pagination.value.total = response.total
|
||||
pagination.value.pages = response.pages
|
||||
|
||||
// Extract models from loaded logs for filter options
|
||||
extractModelsFromLogs()
|
||||
} catch (error) {
|
||||
if (signal.aborted || isAbortError(error)) {
|
||||
return
|
||||
}
|
||||
appStore.showError(t('usage.failedToLoad'))
|
||||
} finally {
|
||||
loading.value = false
|
||||
if (!signal.aborted && abortController === controller) {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -944,27 +971,37 @@ const applyFilters = () => {
|
||||
// Load filter options
|
||||
const loadFilterOptions = async () => {
|
||||
try {
|
||||
// Load accounts
|
||||
const accountsResponse = await adminAPI.accounts.list(1, 1000)
|
||||
const [accountsResponse, groupsResponse] = await Promise.all([
|
||||
adminAPI.accounts.list(1, 1000),
|
||||
adminAPI.groups.list(1, 1000)
|
||||
])
|
||||
accounts.value = accountsResponse.items || []
|
||||
|
||||
// Load groups
|
||||
const groupsResponse = await adminAPI.groups.list(1, 1000)
|
||||
groups.value = groupsResponse.items || []
|
||||
} catch (error) {
|
||||
console.error('Failed to load filter options:', error)
|
||||
}
|
||||
await loadModelOptions()
|
||||
}
|
||||
|
||||
// Extract unique models from usage logs
|
||||
const extractModelsFromLogs = () => {
|
||||
const uniqueModels = new Set<string>()
|
||||
usageLogs.value.forEach(log => {
|
||||
if (log.model) {
|
||||
uniqueModels.add(log.model)
|
||||
}
|
||||
})
|
||||
models.value = Array.from(uniqueModels).sort()
|
||||
const loadModelOptions = async () => {
|
||||
try {
|
||||
const endDate = new Date()
|
||||
const startDateRange = new Date(endDate)
|
||||
startDateRange.setDate(startDateRange.getDate() - 29)
|
||||
const response = await adminAPI.dashboard.getModelStats({
|
||||
start_date: startDateRange.toISOString().split('T')[0],
|
||||
end_date: endDate.toISOString().split('T')[0]
|
||||
})
|
||||
const uniqueModels = new Set<string>()
|
||||
response.models?.forEach((stat) => {
|
||||
if (stat.model) {
|
||||
uniqueModels.add(stat.model)
|
||||
}
|
||||
})
|
||||
models.value = Array.from(uniqueModels).sort()
|
||||
} catch (error) {
|
||||
console.error('Failed to load model options:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const resetFilters = () => {
|
||||
@@ -987,6 +1024,7 @@ const resetFilters = () => {
|
||||
// Reset date range to default (last 7 days)
|
||||
initializeDateRange()
|
||||
pagination.value.page = 1
|
||||
loadApiKeys()
|
||||
loadUsageLogs()
|
||||
loadUsageStats()
|
||||
loadChartData()
|
||||
@@ -997,6 +1035,12 @@ const handlePageChange = (page: number) => {
|
||||
loadUsageLogs()
|
||||
}
|
||||
|
||||
const handlePageSizeChange = (pageSize: number) => {
|
||||
pagination.value.page_size = pageSize
|
||||
pagination.value.page = 1
|
||||
loadUsageLogs()
|
||||
}
|
||||
|
||||
const exportToCSV = () => {
|
||||
if (usageLogs.value.length === 0) {
|
||||
appStore.showWarning(t('usage.noDataToExport'))
|
||||
@@ -1072,6 +1116,7 @@ const hideTooltip = () => {
|
||||
onMounted(() => {
|
||||
initializeDateRange()
|
||||
loadFilterOptions()
|
||||
loadApiKeys()
|
||||
loadUsageLogs()
|
||||
loadUsageStats()
|
||||
loadChartData()
|
||||
@@ -1083,5 +1128,8 @@ onUnmounted(() => {
|
||||
if (searchTimeout) {
|
||||
clearTimeout(searchTimeout)
|
||||
}
|
||||
if (abortController) {
|
||||
abortController.abort()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -198,12 +198,13 @@
|
||||
<span class="text-sm text-gray-500 dark:text-dark-400">{{ formatDateTime(value) }}</span>
|
||||
</template>
|
||||
|
||||
<template #cell-actions="{ row, expanded }">
|
||||
<template #cell-actions="{ row }">
|
||||
<div class="flex items-center gap-1">
|
||||
<!-- 主要操作:编辑和删除(始终显示) -->
|
||||
<!-- Edit Button -->
|
||||
<button
|
||||
@click="handleEdit(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"
|
||||
class="flex h-8 w-8 items-center justify-center rounded-lg text-gray-500 transition-colors hover:bg-gray-100 hover:text-primary-600 dark:hover:bg-dark-700 dark:hover:text-primary-400"
|
||||
:title="t('common.edit')"
|
||||
>
|
||||
<svg
|
||||
class="h-4 w-4"
|
||||
@@ -218,145 +219,29 @@
|
||||
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>
|
||||
<span class="text-xs">{{ t('common.edit') }}</span>
|
||||
</button>
|
||||
<button
|
||||
v-if="row.role !== 'admin'"
|
||||
@click="handleDelete(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"
|
||||
>
|
||||
<svg
|
||||
class="h-4 w-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>
|
||||
<span class="text-xs">{{ t('common.delete') }}</span>
|
||||
</button>
|
||||
|
||||
<!-- 次要操作:展开时显示 -->
|
||||
<template v-if="expanded">
|
||||
<!-- Toggle Status (hidden for admin users) -->
|
||||
<button
|
||||
v-if="row.role !== 'admin'"
|
||||
@click="handleToggleStatus(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-orange-50 hover:text-orange-600 dark:hover:bg-orange-900/20 dark:hover:text-orange-400'
|
||||
: 'text-gray-500 hover:bg-green-50 hover:text-green-600 dark:hover:bg-green-900/20 dark:hover:text-green-400'
|
||||
]"
|
||||
>
|
||||
<!-- More Actions Menu Trigger -->
|
||||
<button
|
||||
:ref="(el) => setActionButtonRef(row.id, el)"
|
||||
@click="openActionMenu(row)"
|
||||
class="action-menu-trigger flex h-8 w-8 items-center justify-center rounded-lg text-gray-500 transition-colors hover:bg-gray-100 hover:text-gray-900 dark:hover:bg-dark-700 dark:hover:text-white"
|
||||
:class="{ 'bg-gray-100 text-gray-900 dark:bg-dark-700 dark:text-white': activeMenuId === row.id }"
|
||||
>
|
||||
<svg
|
||||
v-if="row.status === 'active'"
|
||||
class="h-4 w-4"
|
||||
class="h-5 w-5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
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"
|
||||
d="M6.75 12a.75.75 0 11-1.5 0 .75.75 0 011.5 0zM12.75 12a.75.75 0 11-1.5 0 .75.75 0 011.5 0zM18.75 12a.75.75 0 11-1.5 0 .75.75 0 011.5 0z"
|
||||
/>
|
||||
</svg>
|
||||
<svg
|
||||
v-else
|
||||
class="h-4 w-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>
|
||||
<span class="text-xs">{{ row.status === 'active' ? t('admin.users.disable') : t('admin.users.enable') }}</span>
|
||||
</button>
|
||||
<!-- Allowed Groups -->
|
||||
<button
|
||||
@click="handleAllowedGroups(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"
|
||||
>
|
||||
<svg
|
||||
class="h-4 w-4"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width="1.5"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M18 18.72a9.094 9.094 0 003.741-.479 3 3 0 00-4.682-2.72m.94 3.198l.001.031c0 .225-.012.447-.037.666A11.944 11.944 0 0112 21c-2.17 0-4.207-.576-5.963-1.584A6.062 6.062 0 016 18.719m12 0a5.971 5.971 0 00-.941-3.197m0 0A5.995 5.995 0 0012 12.75a5.995 5.995 0 00-5.058 2.772m0 0a3 3 0 00-4.681 2.72 8.986 8.986 0 003.74.477m.94-3.197a5.971 5.971 0 00-.94 3.197M15 6.75a3 3 0 11-6 0 3 3 0 016 0zm6 3a2.25 2.25 0 11-4.5 0 2.25 2.25 0 014.5 0zm-13.5 0a2.25 2.25 0 11-4.5 0 2.25 2.25 0 014.5 0z"
|
||||
/>
|
||||
</svg>
|
||||
<span class="text-xs">{{ t('admin.users.groups') }}</span>
|
||||
</button>
|
||||
<!-- View API Keys -->
|
||||
<button
|
||||
@click="handleViewApiKeys(row)"
|
||||
class="flex flex-col items-center gap-0.5 rounded-lg p-1.5 text-gray-500 transition-colors hover:bg-purple-50 hover:text-purple-600 dark:hover:bg-purple-900/20 dark:hover:text-purple-400"
|
||||
>
|
||||
<svg
|
||||
class="h-4 w-4"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width="1.5"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M15.75 5.25a3 3 0 013 3m3 0a6 6 0 01-7.029 5.912c-.563-.097-1.159.026-1.563.43L10.5 17.25H8.25v2.25H6v2.25H2.25v-2.818c0-.597.237-1.17.659-1.591l6.499-6.499c.404-.404.527-1 .43-1.563A6 6 0 1121.75 8.25z"
|
||||
/>
|
||||
</svg>
|
||||
<span class="text-xs">{{ t('admin.users.apiKeys') }}</span>
|
||||
</button>
|
||||
<!-- Deposit -->
|
||||
<button
|
||||
@click="handleDeposit(row)"
|
||||
class="flex flex-col items-center gap-0.5 rounded-lg p-1.5 text-gray-500 transition-colors hover:bg-emerald-50 hover:text-emerald-600 dark:hover:bg-emerald-900/20 dark:hover:text-emerald-400"
|
||||
>
|
||||
<svg
|
||||
class="h-4 w-4"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width="1.5"
|
||||
>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
|
||||
</svg>
|
||||
<span class="text-xs">{{ t('admin.users.deposit') }}</span>
|
||||
</button>
|
||||
<!-- Withdraw -->
|
||||
<button
|
||||
@click="handleWithdraw(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"
|
||||
>
|
||||
<svg
|
||||
class="h-4 w-4"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width="1.5"
|
||||
>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M5 12h14" />
|
||||
</svg>
|
||||
<span class="text-xs">{{ t('admin.users.withdraw') }}</span>
|
||||
</button>
|
||||
</template>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -379,18 +264,121 @@
|
||||
:total="pagination.total"
|
||||
:page-size="pagination.page_size"
|
||||
@update:page="handlePageChange"
|
||||
@update:pageSize="handlePageSizeChange"
|
||||
/>
|
||||
</template>
|
||||
</TablePageLayout>
|
||||
|
||||
<!-- Action Menu (Teleported) -->
|
||||
<Teleport to="body">
|
||||
<div
|
||||
v-if="activeMenuId !== null && menuPosition"
|
||||
class="action-menu-content fixed z-[9999] w-48 overflow-hidden rounded-xl bg-white shadow-lg ring-1 ring-black/5 dark:bg-dark-800 dark:ring-white/10"
|
||||
:style="{ top: menuPosition.top + 'px', left: menuPosition.left + 'px' }"
|
||||
>
|
||||
<div class="py-1">
|
||||
<template v-for="user in users" :key="user.id">
|
||||
<template v-if="user.id === activeMenuId">
|
||||
<!-- View API Keys -->
|
||||
<button
|
||||
@click="handleViewApiKeys(user); closeActionMenu()"
|
||||
class="flex w-full items-center gap-2 px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-dark-700"
|
||||
>
|
||||
<svg class="h-4 w-4 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11.536 16.207l-1.414 1.414a2 2 0 01-2.828 0l-1.414-1.414a2 2 0 010-2.828l-1.414-1.414a2 2 0 010-2.828l1.414-1.414L10.257 6.257A6 6 0 1121 11.257V11.257" />
|
||||
</svg>
|
||||
{{ t('admin.users.apiKeys') }}
|
||||
</button>
|
||||
|
||||
<!-- Allowed Groups -->
|
||||
<button
|
||||
@click="handleAllowedGroups(user); closeActionMenu()"
|
||||
class="flex w-full items-center gap-2 px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-dark-700"
|
||||
>
|
||||
<svg class="h-4 w-4 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z" />
|
||||
</svg>
|
||||
{{ t('admin.users.groups') }}
|
||||
</button>
|
||||
|
||||
<div class="my-1 border-t border-gray-100 dark:border-dark-700"></div>
|
||||
|
||||
<!-- Deposit -->
|
||||
<button
|
||||
@click="handleDeposit(user); closeActionMenu()"
|
||||
class="flex w-full items-center gap-2 px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-dark-700"
|
||||
>
|
||||
<svg class="h-4 w-4 text-emerald-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6v6m0 0v6m0-6h6m-6 0H6" />
|
||||
</svg>
|
||||
{{ t('admin.users.deposit') }}
|
||||
</button>
|
||||
|
||||
<!-- Withdraw -->
|
||||
<button
|
||||
@click="handleWithdraw(user); closeActionMenu()"
|
||||
class="flex w-full items-center gap-2 px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-dark-700"
|
||||
>
|
||||
<svg class="h-4 w-4 text-amber-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20 12H4" />
|
||||
</svg>
|
||||
{{ t('admin.users.withdraw') }}
|
||||
</button>
|
||||
|
||||
<div class="my-1 border-t border-gray-100 dark:border-dark-700"></div>
|
||||
|
||||
<!-- Toggle Status (not for admin) -->
|
||||
<button
|
||||
v-if="user.role !== 'admin'"
|
||||
@click="handleToggleStatus(user); closeActionMenu()"
|
||||
class="flex w-full items-center gap-2 px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-dark-700"
|
||||
>
|
||||
<svg
|
||||
v-if="user.status === 'active'"
|
||||
class="h-4 w-4 text-orange-500"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" 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="h-4 w-4 text-green-500"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
{{ user.status === 'active' ? t('admin.users.disable') : t('admin.users.enable') }}
|
||||
</button>
|
||||
|
||||
<!-- Delete (not for admin) -->
|
||||
<button
|
||||
v-if="user.role !== 'admin'"
|
||||
@click="handleDelete(user); closeActionMenu()"
|
||||
class="flex w-full items-center gap-2 px-4 py-2 text-sm text-red-600 hover:bg-red-50 dark:text-red-400 dark:hover:bg-red-900/20"
|
||||
>
|
||||
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||||
</svg>
|
||||
{{ t('common.delete') }}
|
||||
</button>
|
||||
</template>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
|
||||
<!-- Create User Modal -->
|
||||
<Modal
|
||||
<BaseDialog
|
||||
:show="showCreateModal"
|
||||
:title="t('admin.users.createUser')"
|
||||
size="lg"
|
||||
width="normal"
|
||||
@close="closeCreateModal"
|
||||
>
|
||||
<form @submit.prevent="handleCreateUser" class="space-y-5">
|
||||
<form id="create-user-form" @submit.prevent="handleCreateUser" class="space-y-5">
|
||||
<div>
|
||||
<label class="input-label">{{ t('admin.users.email') }}</label>
|
||||
<input
|
||||
@@ -512,12 +500,19 @@
|
||||
<input v-model.number="createForm.concurrency" type="number" class="input" />
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div class="flex justify-end gap-3 pt-4">
|
||||
<template #footer>
|
||||
<div class="flex justify-end gap-3">
|
||||
<button @click="closeCreateModal" type="button" class="btn btn-secondary">
|
||||
{{ t('common.cancel') }}
|
||||
</button>
|
||||
<button type="submit" :disabled="submitting" class="btn btn-primary">
|
||||
<button
|
||||
type="submit"
|
||||
form="create-user-form"
|
||||
:disabled="submitting"
|
||||
class="btn btn-primary"
|
||||
>
|
||||
<svg
|
||||
v-if="submitting"
|
||||
class="-ml-1 mr-2 h-4 w-4 animate-spin"
|
||||
@@ -541,17 +536,22 @@
|
||||
{{ submitting ? t('admin.users.creating') : t('common.create') }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</Modal>
|
||||
</template>
|
||||
</BaseDialog>
|
||||
|
||||
<!-- Edit User Modal -->
|
||||
<Modal
|
||||
<BaseDialog
|
||||
:show="showEditModal"
|
||||
:title="t('admin.users.editUser')"
|
||||
size="lg"
|
||||
width="normal"
|
||||
@close="closeEditModal"
|
||||
>
|
||||
<form v-if="editingUser" @submit.prevent="handleUpdateUser" class="space-y-5">
|
||||
<form
|
||||
v-if="editingUser"
|
||||
id="edit-user-form"
|
||||
@submit.prevent="handleUpdateUser"
|
||||
class="space-y-5"
|
||||
>
|
||||
<div>
|
||||
<label class="input-label">{{ t('admin.users.email') }}</label>
|
||||
<input v-model="editForm.email" type="email" class="input" />
|
||||
@@ -664,11 +664,19 @@
|
||||
<input v-model.number="editForm.concurrency" type="number" class="input" />
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end gap-3 pt-4">
|
||||
</form>
|
||||
|
||||
<template #footer>
|
||||
<div class="flex justify-end gap-3">
|
||||
<button @click="closeEditModal" type="button" class="btn btn-secondary">
|
||||
{{ t('common.cancel') }}
|
||||
</button>
|
||||
<button type="submit" :disabled="submitting" class="btn btn-primary">
|
||||
<button
|
||||
type="submit"
|
||||
form="edit-user-form"
|
||||
:disabled="submitting"
|
||||
class="btn btn-primary"
|
||||
>
|
||||
<svg
|
||||
v-if="submitting"
|
||||
class="-ml-1 mr-2 h-4 w-4 animate-spin"
|
||||
@@ -692,14 +700,14 @@
|
||||
{{ submitting ? t('admin.users.updating') : t('common.update') }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</Modal>
|
||||
</template>
|
||||
</BaseDialog>
|
||||
|
||||
<!-- View API Keys Modal -->
|
||||
<Modal
|
||||
<BaseDialog
|
||||
:show="showApiKeysModal"
|
||||
:title="t('admin.users.userApiKeys')"
|
||||
size="xl"
|
||||
width="wide"
|
||||
@close="closeApiKeysModal"
|
||||
>
|
||||
<div v-if="viewingUser" class="space-y-4">
|
||||
@@ -828,13 +836,13 @@
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
</Modal>
|
||||
</BaseDialog>
|
||||
|
||||
<!-- Allowed Groups Modal -->
|
||||
<Modal
|
||||
<BaseDialog
|
||||
:show="showAllowedGroupsModal"
|
||||
:title="t('admin.users.setAllowedGroups')"
|
||||
size="lg"
|
||||
width="normal"
|
||||
@close="closeAllowedGroupsModal"
|
||||
>
|
||||
<div v-if="allowedGroupsUser" class="space-y-4">
|
||||
@@ -994,16 +1002,21 @@
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
</Modal>
|
||||
</BaseDialog>
|
||||
|
||||
<!-- Deposit/Withdraw Modal -->
|
||||
<Modal
|
||||
<BaseDialog
|
||||
:show="showBalanceModal"
|
||||
:title="balanceOperation === 'add' ? t('admin.users.deposit') : t('admin.users.withdraw')"
|
||||
size="md"
|
||||
width="narrow"
|
||||
@close="closeBalanceModal"
|
||||
>
|
||||
<form v-if="balanceUser" @submit.prevent="handleBalanceSubmit" class="space-y-5">
|
||||
<form
|
||||
v-if="balanceUser"
|
||||
id="balance-form"
|
||||
@submit.prevent="handleBalanceSubmit"
|
||||
class="space-y-5"
|
||||
>
|
||||
<div class="flex items-center gap-3 rounded-xl bg-gray-50 p-4 dark:bg-dark-700">
|
||||
<div
|
||||
class="flex h-10 w-10 items-center justify-center rounded-full bg-primary-100 dark:bg-primary-900/30"
|
||||
@@ -1098,12 +1111,16 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end gap-3 pt-4">
|
||||
</form>
|
||||
|
||||
<template #footer>
|
||||
<div class="flex justify-end gap-3">
|
||||
<button @click="closeBalanceModal" type="button" class="btn btn-secondary">
|
||||
{{ t('common.cancel') }}
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
form="balance-form"
|
||||
:disabled="
|
||||
balanceSubmitting ||
|
||||
!balanceForm.amount ||
|
||||
@@ -1148,8 +1165,8 @@
|
||||
}}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</Modal>
|
||||
</template>
|
||||
</BaseDialog>
|
||||
|
||||
<!-- Delete Confirmation Dialog -->
|
||||
<ConfirmDialog
|
||||
@@ -1166,7 +1183,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, computed, onMounted } from 'vue'
|
||||
import { ref, reactive, computed, onMounted, onUnmounted, type ComponentPublicInstance } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
import { useClipboard } from '@/composables/useClipboard'
|
||||
@@ -1181,7 +1198,7 @@ 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 Modal from '@/components/common/Modal.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'
|
||||
@@ -1244,6 +1261,63 @@ const viewingUser = ref<User | null>(null)
|
||||
const userApiKeys = ref<ApiKey[]>([])
|
||||
const loadingApiKeys = ref(false)
|
||||
const passwordCopied = ref(false)
|
||||
let abortController: AbortController | null = null
|
||||
|
||||
// Action Menu State
|
||||
const activeMenuId = ref<number | null>(null)
|
||||
const menuPosition = ref<{ top: number; left: number } | null>(null)
|
||||
const actionButtonRefs = ref<Map<number, HTMLElement>>(new Map())
|
||||
|
||||
const setActionButtonRef = (userId: number, el: Element | ComponentPublicInstance | null) => {
|
||||
if (el instanceof HTMLElement) {
|
||||
actionButtonRefs.value.set(userId, el)
|
||||
} else {
|
||||
actionButtonRefs.value.delete(userId)
|
||||
}
|
||||
}
|
||||
|
||||
const openActionMenu = (user: User) => {
|
||||
if (activeMenuId.value === user.id) {
|
||||
closeActionMenu()
|
||||
} else {
|
||||
const buttonEl = actionButtonRefs.value.get(user.id)
|
||||
if (buttonEl) {
|
||||
const rect = buttonEl.getBoundingClientRect()
|
||||
const menuWidth = 192
|
||||
const menuHeight = 240
|
||||
const padding = 8
|
||||
const viewportWidth = window.innerWidth
|
||||
const viewportHeight = window.innerHeight
|
||||
const left = Math.min(
|
||||
Math.max(rect.right - menuWidth, padding),
|
||||
Math.max(viewportWidth - menuWidth - padding, padding)
|
||||
)
|
||||
let top = rect.bottom + 4
|
||||
if (top + menuHeight > viewportHeight - padding) {
|
||||
top = Math.max(rect.top - menuHeight - 4, padding)
|
||||
}
|
||||
// Position menu near the trigger, clamped to viewport
|
||||
menuPosition.value = {
|
||||
top,
|
||||
left
|
||||
}
|
||||
}
|
||||
activeMenuId.value = user.id
|
||||
}
|
||||
}
|
||||
|
||||
const closeActionMenu = () => {
|
||||
activeMenuId.value = null
|
||||
menuPosition.value = null
|
||||
}
|
||||
|
||||
// Close menu when clicking outside
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
const target = event.target as HTMLElement
|
||||
if (!target.closest('.action-menu-trigger') && !target.closest('.action-menu-content')) {
|
||||
closeActionMenu()
|
||||
}
|
||||
}
|
||||
|
||||
// Allowed groups modal state
|
||||
const showAllowedGroupsModal = ref(false)
|
||||
@@ -1331,13 +1405,25 @@ const copyEditPassword = async () => {
|
||||
}
|
||||
|
||||
const loadUsers = async () => {
|
||||
abortController?.abort()
|
||||
const currentAbortController = new AbortController()
|
||||
abortController = currentAbortController
|
||||
const { signal } = currentAbortController
|
||||
loading.value = true
|
||||
try {
|
||||
const response = await adminAPI.users.list(pagination.page, pagination.page_size, {
|
||||
role: filters.role as any,
|
||||
status: filters.status as any,
|
||||
search: searchQuery.value || undefined
|
||||
})
|
||||
const response = await adminAPI.users.list(
|
||||
pagination.page,
|
||||
pagination.page_size,
|
||||
{
|
||||
role: filters.role as any,
|
||||
status: filters.status as any,
|
||||
search: searchQuery.value || undefined
|
||||
},
|
||||
{ signal }
|
||||
)
|
||||
if (signal.aborted) {
|
||||
return
|
||||
}
|
||||
users.value = response.items
|
||||
pagination.total = response.total
|
||||
pagination.pages = response.pages
|
||||
@@ -1347,16 +1433,28 @@ const loadUsers = async () => {
|
||||
const userIds = response.items.map((u) => u.id)
|
||||
try {
|
||||
const usageResponse = await adminAPI.dashboard.getBatchUsersUsage(userIds)
|
||||
if (signal.aborted) {
|
||||
return
|
||||
}
|
||||
usageStats.value = usageResponse.stats
|
||||
} catch (e) {
|
||||
if (signal.aborted) {
|
||||
return
|
||||
}
|
||||
console.error('Failed to load usage stats:', e)
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
const errorInfo = error as { name?: string; code?: string }
|
||||
if (errorInfo?.name === 'AbortError' || errorInfo?.name === 'CanceledError' || errorInfo?.code === 'ERR_CANCELED') {
|
||||
return
|
||||
}
|
||||
appStore.showError(t('admin.users.failedToLoad'))
|
||||
console.error('Error loading users:', error)
|
||||
} finally {
|
||||
loading.value = false
|
||||
if (abortController === currentAbortController) {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1374,6 +1472,12 @@ const handlePageChange = (page: number) => {
|
||||
loadUsers()
|
||||
}
|
||||
|
||||
const handlePageSizeChange = (pageSize: number) => {
|
||||
pagination.page_size = pageSize
|
||||
pagination.page = 1
|
||||
loadUsers()
|
||||
}
|
||||
|
||||
const closeCreateModal = () => {
|
||||
showCreateModal.value = false
|
||||
createForm.email = ''
|
||||
@@ -1620,5 +1724,10 @@ const handleBalanceSubmit = async () => {
|
||||
|
||||
onMounted(() => {
|
||||
loadUsers()
|
||||
document.addEventListener('click', handleClickOutside)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
document.removeEventListener('click', handleClickOutside)
|
||||
})
|
||||
</script>
|
||||
|
||||
Reference in New Issue
Block a user