First commit
This commit is contained in:
523
frontend/src/views/admin/AccountsView.vue
Normal file
523
frontend/src/views/admin/AccountsView.vue
Normal file
@@ -0,0 +1,523 @@
|
||||
<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('admin.accounts.createAccount') }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Search and Filters -->
|
||||
<div class="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
|
||||
<div class="relative flex-1 max-w-md">
|
||||
<svg class="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M21 21l-5.197-5.197m0 0A7.5 7.5 0 105.196 5.196a7.5 7.5 0 0010.607 10.607z" />
|
||||
</svg>
|
||||
<input
|
||||
v-model="searchQuery"
|
||||
type="text"
|
||||
:placeholder="t('admin.accounts.searchAccounts')"
|
||||
class="input pl-10"
|
||||
@input="handleSearch"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-3">
|
||||
<Select
|
||||
v-model="filters.platform"
|
||||
:options="platformOptions"
|
||||
:placeholder="t('admin.accounts.allPlatforms')"
|
||||
class="w-40"
|
||||
@change="loadAccounts"
|
||||
/>
|
||||
<Select
|
||||
v-model="filters.type"
|
||||
:options="typeOptions"
|
||||
:placeholder="t('admin.accounts.allTypes')"
|
||||
class="w-40"
|
||||
@change="loadAccounts"
|
||||
/>
|
||||
<Select
|
||||
v-model="filters.status"
|
||||
:options="statusOptions"
|
||||
:placeholder="t('admin.accounts.allStatus')"
|
||||
class="w-36"
|
||||
@change="loadAccounts"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Accounts Table -->
|
||||
<div class="card overflow-hidden">
|
||||
<DataTable :columns="columns" :data="accounts" :loading="loading">
|
||||
<template #cell-name="{ value }">
|
||||
<span class="font-medium text-gray-900 dark:text-white">{{ value }}</span>
|
||||
</template>
|
||||
|
||||
<template #cell-platform="{ value }">
|
||||
<div class="flex items-center gap-2">
|
||||
<span
|
||||
:class="[
|
||||
'w-2 h-2 rounded-full',
|
||||
value === 'anthropic' ? 'bg-orange-500' : 'bg-gray-400'
|
||||
]"
|
||||
/>
|
||||
<span class="text-sm text-gray-700 dark:text-gray-300 capitalize">{{ value === 'anthropic' ? 'Anthropic' : value }}</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #cell-type="{ value }">
|
||||
<span
|
||||
:class="[
|
||||
'badge',
|
||||
value === 'oauth' ? 'badge-primary' : value === 'setup-token' ? 'badge-info' : 'badge-purple'
|
||||
]"
|
||||
>
|
||||
{{ value === 'oauth' ? 'Oauth' : value === 'setup-token' ? t('admin.accounts.setupToken') : t('admin.accounts.apiKey') }}
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<template #cell-status="{ row }">
|
||||
<AccountStatusIndicator :account="row" />
|
||||
</template>
|
||||
|
||||
<template #cell-schedulable="{ row }">
|
||||
<button
|
||||
@click="handleToggleSchedulable(row)"
|
||||
:disabled="togglingSchedulable === row.id"
|
||||
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 focus:ring-2 focus:ring-primary-500 focus:ring-offset-2 dark:focus:ring-offset-dark-800 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
:class="[
|
||||
row.schedulable
|
||||
? 'bg-primary-500 hover:bg-primary-600'
|
||||
: 'bg-gray-200 dark:bg-dark-600 hover:bg-gray-300 dark:hover:bg-dark-500'
|
||||
]"
|
||||
:title="row.schedulable ? t('admin.accounts.schedulableEnabled') : t('admin.accounts.schedulableDisabled')"
|
||||
>
|
||||
<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"
|
||||
:class="[row.schedulable ? 'translate-x-4' : 'translate-x-0']"
|
||||
/>
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<template #cell-today_stats="{ row }">
|
||||
<AccountTodayStatsCell :account="row" />
|
||||
</template>
|
||||
|
||||
<template #cell-usage="{ row }">
|
||||
<AccountUsageCell :account="row" />
|
||||
</template>
|
||||
|
||||
<template #cell-priority="{ value }">
|
||||
<span class="text-sm text-gray-700 dark:text-gray-300">{{ value }}</span>
|
||||
</template>
|
||||
|
||||
<template #cell-last_used_at="{ value }">
|
||||
<span class="text-sm text-gray-500 dark:text-dark-400">
|
||||
{{ formatRelativeTime(value) }}
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<template #cell-actions="{ row }">
|
||||
<div class="flex items-center gap-1">
|
||||
<!-- Clear Rate Limit button -->
|
||||
<button
|
||||
v-if="isRateLimited(row) || isOverloaded(row)"
|
||||
@click="handleClearRateLimit(row)"
|
||||
class="p-2 rounded-lg hover:bg-amber-50 dark:hover:bg-amber-900/20 text-amber-500 hover:text-amber-600 dark:hover:text-amber-400 transition-colors"
|
||||
:title="t('admin.accounts.clearRateLimit')"
|
||||
>
|
||||
<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="M12 6v6h4.5m4.5 0a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
</button>
|
||||
<!-- Test Connection button -->
|
||||
<button
|
||||
@click="handleTest(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('admin.accounts.testConnection')"
|
||||
>
|
||||
<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="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>
|
||||
</button>
|
||||
<button
|
||||
v-if="row.type === 'oauth' || row.type === 'setup-token'"
|
||||
@click="handleReAuth(row)"
|
||||
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('admin.accounts.reAuthorize')"
|
||||
>
|
||||
<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="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>
|
||||
</button>
|
||||
<button
|
||||
v-if="row.type === 'oauth' || row.type === 'setup-token'"
|
||||
@click="handleRefreshToken(row)"
|
||||
class="p-2 rounded-lg hover:bg-purple-50 dark:hover:bg-purple-900/20 text-gray-500 hover:text-purple-600 dark:hover:text-purple-400 transition-colors"
|
||||
:title="t('admin.accounts.refreshToken')"
|
||||
>
|
||||
<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.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>
|
||||
</button>
|
||||
<button
|
||||
@click="handleEdit(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="t('common.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>
|
||||
<button
|
||||
@click="handleDelete(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="t('common.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('admin.accounts.noAccountsYet')"
|
||||
:description="t('admin.accounts.createFirstAccount')"
|
||||
:action-text="t('admin.accounts.createAccount')"
|
||||
@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 Account Modal -->
|
||||
<CreateAccountModal
|
||||
:show="showCreateModal"
|
||||
:proxies="proxies"
|
||||
:groups="groups"
|
||||
@close="showCreateModal = false"
|
||||
@created="loadAccounts"
|
||||
/>
|
||||
|
||||
<!-- Edit Account Modal -->
|
||||
<EditAccountModal
|
||||
:show="showEditModal"
|
||||
:account="editingAccount"
|
||||
:proxies="proxies"
|
||||
:groups="groups"
|
||||
@close="closeEditModal"
|
||||
@updated="loadAccounts"
|
||||
/>
|
||||
|
||||
<!-- Re-Auth Modal -->
|
||||
<ReAuthAccountModal
|
||||
:show="showReAuthModal"
|
||||
:account="reAuthAccount"
|
||||
@close="closeReAuthModal"
|
||||
@reauthorized="loadAccounts"
|
||||
/>
|
||||
|
||||
<!-- Test Account Modal -->
|
||||
<AccountTestModal
|
||||
:show="showTestModal"
|
||||
:account="testingAccount"
|
||||
@close="closeTestModal"
|
||||
/>
|
||||
|
||||
<!-- Delete Confirmation Dialog -->
|
||||
<ConfirmDialog
|
||||
:show="showDeleteDialog"
|
||||
:title="t('admin.accounts.deleteAccount')"
|
||||
:message="t('admin.accounts.deleteConfirm', { name: deletingAccount?.name })"
|
||||
:confirm-text="t('common.delete')"
|
||||
:cancel-text="t('common.cancel')"
|
||||
:danger="true"
|
||||
@confirm="confirmDelete"
|
||||
@cancel="showDeleteDialog = false"
|
||||
/>
|
||||
</AppLayout>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, computed, onMounted } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
import { adminAPI } from '@/api/admin'
|
||||
import type { Account, Proxy, Group } from '@/types'
|
||||
import type { Column } from '@/components/common/DataTable.vue'
|
||||
import AppLayout from '@/components/layout/AppLayout.vue'
|
||||
import DataTable from '@/components/common/DataTable.vue'
|
||||
import Pagination from '@/components/common/Pagination.vue'
|
||||
import ConfirmDialog from '@/components/common/ConfirmDialog.vue'
|
||||
import EmptyState from '@/components/common/EmptyState.vue'
|
||||
import Select from '@/components/common/Select.vue'
|
||||
import { CreateAccountModal, EditAccountModal, ReAuthAccountModal } from '@/components/account'
|
||||
import AccountStatusIndicator from '@/components/account/AccountStatusIndicator.vue'
|
||||
import AccountUsageCell from '@/components/account/AccountUsageCell.vue'
|
||||
import AccountTodayStatsCell from '@/components/account/AccountTodayStatsCell.vue'
|
||||
import AccountTestModal from '@/components/account/AccountTestModal.vue'
|
||||
import { formatRelativeTime } from '@/utils/format'
|
||||
|
||||
const { t } = useI18n()
|
||||
const appStore = useAppStore()
|
||||
|
||||
// Table columns
|
||||
const columns = computed<Column[]>(() => [
|
||||
{ key: 'name', label: t('admin.accounts.columns.name'), sortable: true },
|
||||
{ key: 'platform', label: t('admin.accounts.columns.platform'), sortable: true },
|
||||
{ key: 'type', label: t('admin.accounts.columns.type'), sortable: true },
|
||||
{ key: 'status', label: t('admin.accounts.columns.status'), sortable: true },
|
||||
{ key: 'schedulable', label: t('admin.accounts.columns.schedulable'), sortable: true },
|
||||
{ key: 'today_stats', label: t('admin.accounts.columns.todayStats'), sortable: false },
|
||||
{ key: 'usage', label: t('admin.accounts.columns.usageWindows'), sortable: false },
|
||||
{ key: 'priority', label: t('admin.accounts.columns.priority'), sortable: true },
|
||||
{ key: 'last_used_at', label: t('admin.accounts.columns.lastUsed'), sortable: true },
|
||||
{ key: 'actions', label: t('admin.accounts.columns.actions'), sortable: false }
|
||||
])
|
||||
|
||||
// Filter options
|
||||
const platformOptions = computed(() => [
|
||||
{ value: '', label: t('admin.accounts.allPlatforms') },
|
||||
{ value: 'anthropic', label: t('admin.accounts.platforms.anthropic') }
|
||||
])
|
||||
|
||||
const typeOptions = computed(() => [
|
||||
{ value: '', label: t('admin.accounts.allTypes') },
|
||||
{ value: 'oauth', label: t('admin.accounts.oauthType') },
|
||||
{ value: 'setup-token', label: t('admin.accounts.setupToken') },
|
||||
{ value: 'apikey', label: t('admin.accounts.apiKey') }
|
||||
])
|
||||
|
||||
const statusOptions = computed(() => [
|
||||
{ value: '', label: t('admin.accounts.allStatus') },
|
||||
{ value: 'active', label: t('common.active') },
|
||||
{ value: 'inactive', label: t('common.inactive') },
|
||||
{ value: 'error', label: t('common.error') }
|
||||
])
|
||||
|
||||
// State
|
||||
const accounts = ref<Account[]>([])
|
||||
const proxies = ref<Proxy[]>([])
|
||||
const groups = ref<Group[]>([])
|
||||
const loading = ref(false)
|
||||
const searchQuery = ref('')
|
||||
const filters = reactive({
|
||||
platform: '',
|
||||
type: '',
|
||||
status: '',
|
||||
})
|
||||
const pagination = reactive({
|
||||
page: 1,
|
||||
page_size: 20,
|
||||
total: 0,
|
||||
pages: 0
|
||||
})
|
||||
|
||||
// Modal states
|
||||
const showCreateModal = ref(false)
|
||||
const showEditModal = ref(false)
|
||||
const showReAuthModal = ref(false)
|
||||
const showDeleteDialog = ref(false)
|
||||
const showTestModal = ref(false)
|
||||
const editingAccount = ref<Account | null>(null)
|
||||
const reAuthAccount = ref<Account | null>(null)
|
||||
const deletingAccount = ref<Account | null>(null)
|
||||
const testingAccount = ref<Account | null>(null)
|
||||
const togglingSchedulable = ref<number | null>(null)
|
||||
|
||||
// Rate limit / Overload helpers
|
||||
const isRateLimited = (account: Account): boolean => {
|
||||
if (!account.rate_limit_reset_at) return false
|
||||
return new Date(account.rate_limit_reset_at) > new Date()
|
||||
}
|
||||
|
||||
const isOverloaded = (account: Account): boolean => {
|
||||
if (!account.overload_until) return false
|
||||
return new Date(account.overload_until) > new Date()
|
||||
}
|
||||
|
||||
// Data loading
|
||||
const loadAccounts = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const response = await adminAPI.accounts.list(
|
||||
pagination.page,
|
||||
pagination.page_size,
|
||||
{
|
||||
platform: filters.platform || undefined,
|
||||
type: filters.type || undefined,
|
||||
status: filters.status || undefined,
|
||||
search: searchQuery.value || undefined
|
||||
}
|
||||
)
|
||||
accounts.value = response.items
|
||||
pagination.total = response.total
|
||||
pagination.pages = response.pages
|
||||
} catch (error) {
|
||||
appStore.showError(t('admin.accounts.failedToLoad'))
|
||||
console.error('Error loading accounts:', error)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const loadProxies = async () => {
|
||||
try {
|
||||
proxies.value = await adminAPI.proxies.getAllWithCount()
|
||||
} catch (error) {
|
||||
console.error('Error loading proxies:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const loadGroups = async () => {
|
||||
try {
|
||||
groups.value = await adminAPI.groups.getByPlatform('anthropic')
|
||||
} catch (error) {
|
||||
console.error('Error loading groups:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// Search handling
|
||||
let searchTimeout: ReturnType<typeof setTimeout>
|
||||
const handleSearch = () => {
|
||||
clearTimeout(searchTimeout)
|
||||
searchTimeout = setTimeout(() => {
|
||||
pagination.page = 1
|
||||
loadAccounts()
|
||||
}, 300)
|
||||
}
|
||||
|
||||
// Pagination
|
||||
const handlePageChange = (page: number) => {
|
||||
pagination.page = page
|
||||
loadAccounts()
|
||||
}
|
||||
|
||||
// Edit modal
|
||||
const handleEdit = (account: Account) => {
|
||||
editingAccount.value = account
|
||||
showEditModal.value = true
|
||||
}
|
||||
|
||||
const closeEditModal = () => {
|
||||
showEditModal.value = false
|
||||
editingAccount.value = null
|
||||
}
|
||||
|
||||
// Re-Auth modal
|
||||
const handleReAuth = (account: Account) => {
|
||||
reAuthAccount.value = account
|
||||
showReAuthModal.value = true
|
||||
}
|
||||
|
||||
const closeReAuthModal = () => {
|
||||
showReAuthModal.value = false
|
||||
reAuthAccount.value = null
|
||||
}
|
||||
|
||||
// Token refresh
|
||||
const handleRefreshToken = async (account: Account) => {
|
||||
try {
|
||||
await adminAPI.accounts.refreshCredentials(account.id)
|
||||
appStore.showSuccess(t('admin.accounts.tokenRefreshed'))
|
||||
loadAccounts()
|
||||
} catch (error: any) {
|
||||
appStore.showError(error.response?.data?.detail || t('admin.accounts.failedToRefresh'))
|
||||
console.error('Error refreshing token:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// Delete
|
||||
const handleDelete = (account: Account) => {
|
||||
deletingAccount.value = account
|
||||
showDeleteDialog.value = true
|
||||
}
|
||||
|
||||
const confirmDelete = async () => {
|
||||
if (!deletingAccount.value) return
|
||||
|
||||
try {
|
||||
await adminAPI.accounts.delete(deletingAccount.value.id)
|
||||
appStore.showSuccess(t('admin.accounts.accountDeleted'))
|
||||
showDeleteDialog.value = false
|
||||
deletingAccount.value = null
|
||||
loadAccounts()
|
||||
} catch (error: any) {
|
||||
appStore.showError(error.response?.data?.detail || t('admin.accounts.failedToDelete'))
|
||||
console.error('Error deleting account:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// Clear rate limit
|
||||
const handleClearRateLimit = async (account: Account) => {
|
||||
try {
|
||||
await adminAPI.accounts.clearRateLimit(account.id)
|
||||
appStore.showSuccess(t('admin.accounts.rateLimitCleared'))
|
||||
loadAccounts()
|
||||
} catch (error: any) {
|
||||
appStore.showError(error.response?.data?.detail || t('admin.accounts.failedToClearRateLimit'))
|
||||
console.error('Error clearing rate limit:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// Toggle schedulable
|
||||
const handleToggleSchedulable = async (account: Account) => {
|
||||
togglingSchedulable.value = account.id
|
||||
try {
|
||||
const updatedAccount = await adminAPI.accounts.setSchedulable(account.id, !account.schedulable)
|
||||
const index = accounts.value.findIndex(a => a.id === account.id)
|
||||
if (index !== -1) {
|
||||
accounts.value[index] = updatedAccount
|
||||
}
|
||||
appStore.showSuccess(
|
||||
updatedAccount.schedulable
|
||||
? t('admin.accounts.schedulableEnabled')
|
||||
: t('admin.accounts.schedulableDisabled')
|
||||
)
|
||||
} catch (error: any) {
|
||||
appStore.showError(error.response?.data?.detail || t('admin.accounts.failedToToggleSchedulable'))
|
||||
console.error('Error toggling schedulable:', error)
|
||||
} finally {
|
||||
togglingSchedulable.value = null
|
||||
}
|
||||
}
|
||||
|
||||
// Test modal
|
||||
const handleTest = (account: Account) => {
|
||||
testingAccount.value = account
|
||||
showTestModal.value = true
|
||||
}
|
||||
|
||||
const closeTestModal = () => {
|
||||
showTestModal.value = false
|
||||
testingAccount.value = null
|
||||
}
|
||||
|
||||
// Initialize
|
||||
onMounted(() => {
|
||||
loadAccounts()
|
||||
loadProxies()
|
||||
loadGroups()
|
||||
})
|
||||
</script>
|
||||
619
frontend/src/views/admin/DashboardView.vue
Normal file
619
frontend/src/views/admin/DashboardView.vue
Normal file
@@ -0,0 +1,619 @@
|
||||
<template>
|
||||
<AppLayout>
|
||||
<div class="space-y-6">
|
||||
<!-- Loading State -->
|
||||
<div v-if="loading" class="flex items-center justify-center py-12">
|
||||
<LoadingSpinner />
|
||||
</div>
|
||||
|
||||
<template v-else-if="stats">
|
||||
<!-- Row 1: Core Stats -->
|
||||
<div class="grid grid-cols-2 gap-4 lg:grid-cols-4">
|
||||
<!-- Total API Keys -->
|
||||
<div class="card p-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="p-2 rounded-lg bg-blue-100 dark:bg-blue-900/30">
|
||||
<svg class="w-5 h-5 text-blue-600 dark:text-blue-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" 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>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs font-medium text-gray-500 dark:text-gray-400">{{ t('admin.dashboard.apiKeys') }}</p>
|
||||
<p class="text-xl font-bold text-gray-900 dark:text-white">{{ stats.total_api_keys }}</p>
|
||||
<p class="text-xs text-green-600 dark:text-green-400">{{ stats.active_api_keys }} {{ t('common.active') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Service Accounts -->
|
||||
<div class="card p-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="p-2 rounded-lg bg-purple-100 dark:bg-purple-900/30">
|
||||
<svg class="w-5 h-5 text-purple-600 dark:text-purple-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5.25 14.25h13.5m-13.5 0a3 3 0 01-3-3m3 3a3 3 0 100 6h13.5a3 3 0 100-6m-16.5-3a3 3 0 013-3h13.5a3 3 0 013 3m-19.5 0a4.5 4.5 0 01.9-2.7L5.737 5.1a3.375 3.375 0 012.7-1.35h7.126c1.062 0 2.062.5 2.7 1.35l2.587 3.45a4.5 4.5 0 01.9 2.7m0 0a3 3 0 01-3 3m0 3h.008v.008h-.008v-.008zm0-6h.008v.008h-.008v-.008zm-3 6h.008v.008h-.008v-.008zm0-6h.008v.008h-.008v-.008z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs font-medium text-gray-500 dark:text-gray-400">{{ t('admin.dashboard.accounts') }}</p>
|
||||
<p class="text-xl font-bold text-gray-900 dark:text-white">{{ stats.total_accounts }}</p>
|
||||
<p class="text-xs">
|
||||
<span class="text-green-600 dark:text-green-400">{{ stats.normal_accounts }} {{ t('common.active') }}</span>
|
||||
<span v-if="stats.error_accounts > 0" class="text-red-500 ml-1">{{ stats.error_accounts }} {{ t('common.error') }}</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Today Requests -->
|
||||
<div class="card p-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="p-2 rounded-lg bg-green-100 dark:bg-green-900/30">
|
||||
<svg class="w-5 h-5 text-green-600 dark:text-green-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" 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>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs font-medium text-gray-500 dark:text-gray-400">{{ t('admin.dashboard.todayRequests') }}</p>
|
||||
<p class="text-xl font-bold text-gray-900 dark:text-white">{{ stats.today_requests }}</p>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400">{{ t('common.total') }}: {{ formatNumber(stats.total_requests) }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- New Users Today -->
|
||||
<div class="card p-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="p-2 rounded-lg bg-emerald-100 dark:bg-emerald-900/30">
|
||||
<svg class="w-5 h-5 text-emerald-600 dark:text-emerald-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M18 9v3m0 0v3m0-3h3m-3 0h-3m-2-5a4 4 0 11-8 0 4 4 0 018 0zM3 20a6 6 0 0112 0v1H3v-1z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs font-medium text-gray-500 dark:text-gray-400">{{ t('admin.dashboard.users') }}</p>
|
||||
<p class="text-xl font-bold text-emerald-600 dark:text-emerald-400">+{{ stats.today_new_users }}</p>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400">{{ t('common.total') }}: {{ formatNumber(stats.total_users) }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Row 2: Token Stats -->
|
||||
<div class="grid grid-cols-2 gap-4 lg:grid-cols-4">
|
||||
<!-- Today Tokens -->
|
||||
<div class="card p-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="p-2 rounded-lg bg-amber-100 dark:bg-amber-900/30">
|
||||
<svg class="w-5 h-5 text-amber-600 dark:text-amber-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="m21 7.5-9-5.25L3 7.5m18 0-9 5.25m9-5.25v9l-9 5.25M3 7.5l9 5.25M3 7.5v9l9 5.25m0-9v9" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs font-medium text-gray-500 dark:text-gray-400">{{ t('admin.dashboard.todayTokens') }}</p>
|
||||
<p class="text-xl font-bold text-gray-900 dark:text-white">{{ formatTokens(stats.today_tokens) }}</p>
|
||||
<p class="text-xs">
|
||||
<span class="text-amber-600 dark:text-amber-400" :title="t('admin.dashboard.actual')">${{ formatCost(stats.today_actual_cost) }}</span>
|
||||
<span class="text-gray-400 dark:text-gray-500" :title="t('admin.dashboard.standard')"> / ${{ formatCost(stats.today_cost) }}</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Total Tokens -->
|
||||
<div class="card p-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="p-2 rounded-lg bg-indigo-100 dark:bg-indigo-900/30">
|
||||
<svg class="w-5 h-5 text-indigo-600 dark:text-indigo-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20.25 6.375c0 2.278-3.694 4.125-8.25 4.125S3.75 8.653 3.75 6.375m16.5 0c0-2.278-3.694-4.125-8.25-4.125S3.75 4.097 3.75 6.375m16.5 0v11.25c0 2.278-3.694 4.125-8.25 4.125s-8.25-1.847-8.25-4.125V6.375m16.5 0v3.75m-16.5-3.75v3.75m16.5 0v3.75C20.25 16.153 16.556 18 12 18s-8.25-1.847-8.25-4.125v-3.75m16.5 0c0 2.278-3.694 4.125-8.25 4.125s-8.25-1.847-8.25-4.125" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs font-medium text-gray-500 dark:text-gray-400">{{ t('admin.dashboard.totalTokens') }}</p>
|
||||
<p class="text-xl font-bold text-gray-900 dark:text-white">{{ formatTokens(stats.total_tokens) }}</p>
|
||||
<p class="text-xs">
|
||||
<span class="text-indigo-600 dark:text-indigo-400" :title="t('admin.dashboard.actual')">${{ formatCost(stats.total_actual_cost) }}</span>
|
||||
<span class="text-gray-400 dark:text-gray-500" :title="t('admin.dashboard.standard')"> / ${{ formatCost(stats.total_cost) }}</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Cache Tokens -->
|
||||
<div class="card p-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="p-2 rounded-lg bg-cyan-100 dark:bg-cyan-900/30">
|
||||
<svg class="w-5 h-5 text-cyan-600 dark:text-cyan-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25m.75 12l3 3m0 0l3-3m-3 3v-6m-1.5-9H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs font-medium text-gray-500 dark:text-gray-400">{{ t('admin.dashboard.cacheToday') }}</p>
|
||||
<p class="text-xl font-bold text-gray-900 dark:text-white">{{ formatTokens(stats.today_cache_read_tokens) }}</p>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ t('common.create') }}: {{ formatTokens(stats.today_cache_creation_tokens) }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Avg Response Time -->
|
||||
<div class="card p-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="p-2 rounded-lg bg-rose-100 dark:bg-rose-900/30">
|
||||
<svg class="w-5 h-5 text-rose-600 dark:text-rose-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6v6h4.5m4.5 0a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs font-medium text-gray-500 dark:text-gray-400">{{ t('admin.dashboard.avgResponse') }}</p>
|
||||
<p class="text-xl font-bold text-gray-900 dark:text-white">{{ formatDuration(stats.average_duration_ms) }}</p>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400">{{ stats.active_users }} {{ t('admin.dashboard.activeUsers') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Charts Section -->
|
||||
<div class="space-y-6">
|
||||
<!-- Date Range Filter -->
|
||||
<div class="card p-4">
|
||||
<div class="flex flex-wrap items-center gap-4">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">{{ t('admin.dashboard.timeRange') }}:</span>
|
||||
<DateRangePicker
|
||||
v-model:start-date="startDate"
|
||||
v-model:end-date="endDate"
|
||||
@change="onDateRangeChange"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 ml-auto">
|
||||
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">{{ t('admin.dashboard.granularity') }}:</span>
|
||||
<div class="w-28">
|
||||
<Select
|
||||
v-model="granularity"
|
||||
:options="granularityOptions"
|
||||
@change="loadChartData"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Charts Grid -->
|
||||
<div class="grid grid-cols-1 gap-6 lg:grid-cols-2">
|
||||
<!-- Model Distribution Chart -->
|
||||
<div class="card p-4">
|
||||
<h3 class="text-sm font-semibold text-gray-900 dark:text-white mb-4">{{ t('admin.dashboard.modelDistribution') }}</h3>
|
||||
<div class="flex items-center gap-6">
|
||||
<div class="w-48 h-48">
|
||||
<Doughnut v-if="modelChartData" :data="modelChartData" :options="doughnutOptions" />
|
||||
<div v-else class="flex items-center justify-center h-full text-gray-500 dark:text-gray-400 text-sm">
|
||||
{{ t('admin.dashboard.noDataAvailable') }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex-1 max-h-48 overflow-y-auto">
|
||||
<table class="w-full text-xs">
|
||||
<thead>
|
||||
<tr class="text-gray-500 dark:text-gray-400">
|
||||
<th class="text-left pb-2">{{ t('admin.dashboard.model') }}</th>
|
||||
<th class="text-right pb-2">{{ t('admin.dashboard.requests') }}</th>
|
||||
<th class="text-right pb-2">{{ t('admin.dashboard.tokens') }}</th>
|
||||
<th class="text-right pb-2">{{ t('admin.dashboard.actual') }}</th>
|
||||
<th class="text-right pb-2">{{ t('admin.dashboard.standard') }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="model in modelStats" :key="model.model" class="border-t border-gray-100 dark:border-gray-700">
|
||||
<td class="py-1.5 text-gray-900 dark:text-white font-medium truncate max-w-[100px]" :title="model.model">{{ model.model }}</td>
|
||||
<td class="py-1.5 text-right text-gray-600 dark:text-gray-400">{{ formatNumber(model.requests) }}</td>
|
||||
<td class="py-1.5 text-right text-gray-600 dark:text-gray-400">{{ formatTokens(model.total_tokens) }}</td>
|
||||
<td class="py-1.5 text-right text-green-600 dark:text-green-400">${{ formatCost(model.actual_cost) }}</td>
|
||||
<td class="py-1.5 text-right text-gray-400 dark:text-gray-500">${{ formatCost(model.cost) }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Token Usage Trend Chart -->
|
||||
<div class="card p-4">
|
||||
<h3 class="text-sm font-semibold text-gray-900 dark:text-white mb-4">{{ t('admin.dashboard.tokenUsageTrend') }}</h3>
|
||||
<div class="h-48">
|
||||
<Line v-if="trendChartData" :data="trendChartData" :options="lineOptions" />
|
||||
<div v-else class="flex items-center justify-center h-full text-gray-500 dark:text-gray-400 text-sm">
|
||||
{{ t('admin.dashboard.noDataAvailable') }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- User Usage Trend (Full Width) -->
|
||||
<div class="card p-4">
|
||||
<h3 class="text-sm font-semibold text-gray-900 dark:text-white mb-4">{{ t('admin.dashboard.recentUsage') }} (Top 12)</h3>
|
||||
<div class="h-64">
|
||||
<Line v-if="userTrendChartData" :data="userTrendChartData" :options="lineOptions" />
|
||||
<div v-else class="flex items-center justify-center h-full text-gray-500 dark:text-gray-400 text-sm">
|
||||
{{ t('admin.dashboard.noDataAvailable') }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</AppLayout>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
|
||||
const { t } = useI18n()
|
||||
import { adminAPI } from '@/api/admin'
|
||||
import type { DashboardStats, TrendDataPoint, ModelStat, UserUsageTrendPoint } from '@/types'
|
||||
import AppLayout from '@/components/layout/AppLayout.vue'
|
||||
import LoadingSpinner from '@/components/common/LoadingSpinner.vue'
|
||||
import DateRangePicker from '@/components/common/DateRangePicker.vue'
|
||||
import Select from '@/components/common/Select.vue'
|
||||
|
||||
import {
|
||||
Chart as ChartJS,
|
||||
CategoryScale,
|
||||
LinearScale,
|
||||
PointElement,
|
||||
LineElement,
|
||||
ArcElement,
|
||||
Title,
|
||||
Tooltip,
|
||||
Legend,
|
||||
Filler
|
||||
} from 'chart.js'
|
||||
import { Line, Doughnut } from 'vue-chartjs'
|
||||
|
||||
// Register Chart.js components
|
||||
ChartJS.register(
|
||||
CategoryScale,
|
||||
LinearScale,
|
||||
PointElement,
|
||||
LineElement,
|
||||
ArcElement,
|
||||
Title,
|
||||
Tooltip,
|
||||
Legend,
|
||||
Filler
|
||||
)
|
||||
|
||||
const appStore = useAppStore()
|
||||
const stats = ref<DashboardStats | null>(null)
|
||||
const loading = ref(false)
|
||||
|
||||
// Chart data
|
||||
const trendData = ref<TrendDataPoint[]>([])
|
||||
const modelStats = ref<ModelStat[]>([])
|
||||
const userTrend = ref<UserUsageTrendPoint[]>([])
|
||||
|
||||
// Date range
|
||||
const granularity = ref<'day' | 'hour'>('day')
|
||||
const startDate = ref('')
|
||||
const endDate = ref('')
|
||||
|
||||
// Granularity options for Select component
|
||||
const granularityOptions = computed(() => [
|
||||
{ value: 'day', label: t('admin.dashboard.day') },
|
||||
{ value: 'hour', label: t('admin.dashboard.hour') },
|
||||
])
|
||||
|
||||
// Dark mode detection
|
||||
const isDarkMode = computed(() => {
|
||||
return document.documentElement.classList.contains('dark')
|
||||
})
|
||||
|
||||
// Chart colors
|
||||
const chartColors = computed(() => ({
|
||||
text: isDarkMode.value ? '#e5e7eb' : '#374151',
|
||||
grid: isDarkMode.value ? '#374151' : '#e5e7eb',
|
||||
input: '#3b82f6',
|
||||
output: '#10b981',
|
||||
cache: '#f59e0b',
|
||||
total: '#8b5cf6',
|
||||
}))
|
||||
|
||||
// Doughnut chart options
|
||||
const doughnutOptions = computed(() => ({
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: {
|
||||
display: false,
|
||||
},
|
||||
tooltip: {
|
||||
callbacks: {
|
||||
label: (context: any) => {
|
||||
const value = context.raw as number
|
||||
const total = context.dataset.data.reduce((a: number, b: number) => a + b, 0)
|
||||
const percentage = ((value / total) * 100).toFixed(1)
|
||||
return `${context.label}: ${formatTokens(value)} (${percentage}%)`
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}))
|
||||
|
||||
// Line chart options
|
||||
const lineOptions = computed(() => ({
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
interaction: {
|
||||
intersect: false,
|
||||
mode: 'index' as const,
|
||||
},
|
||||
plugins: {
|
||||
legend: {
|
||||
position: 'top' as const,
|
||||
labels: {
|
||||
color: chartColors.value.text,
|
||||
usePointStyle: true,
|
||||
pointStyle: 'circle',
|
||||
padding: 15,
|
||||
font: {
|
||||
size: 11,
|
||||
},
|
||||
},
|
||||
},
|
||||
tooltip: {
|
||||
callbacks: {
|
||||
label: (context: any) => {
|
||||
return `${context.dataset.label}: ${formatTokens(context.raw)}`
|
||||
},
|
||||
footer: (tooltipItems: any) => {
|
||||
// Show both costs for the day if we have trend data
|
||||
const dataIndex = tooltipItems[0]?.dataIndex
|
||||
if (dataIndex !== undefined && trendData.value[dataIndex]) {
|
||||
const data = trendData.value[dataIndex]
|
||||
return `Actual: $${formatCost(data.actual_cost)} | Standard: $${formatCost(data.cost)}`
|
||||
}
|
||||
return ''
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
grid: {
|
||||
color: chartColors.value.grid,
|
||||
},
|
||||
ticks: {
|
||||
color: chartColors.value.text,
|
||||
font: {
|
||||
size: 10,
|
||||
},
|
||||
},
|
||||
},
|
||||
y: {
|
||||
grid: {
|
||||
color: chartColors.value.grid,
|
||||
},
|
||||
ticks: {
|
||||
color: chartColors.value.text,
|
||||
font: {
|
||||
size: 10,
|
||||
},
|
||||
callback: (value: number) => formatTokens(value),
|
||||
},
|
||||
},
|
||||
},
|
||||
}))
|
||||
|
||||
// Model chart data
|
||||
const modelChartData = computed(() => {
|
||||
if (!modelStats.value.length) return null
|
||||
|
||||
const colors = [
|
||||
'#3b82f6', '#10b981', '#f59e0b', '#ef4444', '#8b5cf6',
|
||||
'#ec4899', '#14b8a6', '#f97316', '#6366f1', '#84cc16'
|
||||
]
|
||||
|
||||
return {
|
||||
labels: modelStats.value.map(m => m.model),
|
||||
datasets: [{
|
||||
data: modelStats.value.map(m => m.total_tokens),
|
||||
backgroundColor: colors.slice(0, modelStats.value.length),
|
||||
borderWidth: 0,
|
||||
}],
|
||||
}
|
||||
})
|
||||
|
||||
// Trend chart data
|
||||
const trendChartData = computed(() => {
|
||||
if (!trendData.value.length) return null
|
||||
|
||||
return {
|
||||
labels: trendData.value.map(d => d.date),
|
||||
datasets: [
|
||||
{
|
||||
label: 'Input',
|
||||
data: trendData.value.map(d => d.input_tokens),
|
||||
borderColor: chartColors.value.input,
|
||||
backgroundColor: `${chartColors.value.input}20`,
|
||||
fill: true,
|
||||
tension: 0.3,
|
||||
},
|
||||
{
|
||||
label: 'Output',
|
||||
data: trendData.value.map(d => d.output_tokens),
|
||||
borderColor: chartColors.value.output,
|
||||
backgroundColor: `${chartColors.value.output}20`,
|
||||
fill: true,
|
||||
tension: 0.3,
|
||||
},
|
||||
{
|
||||
label: 'Cache',
|
||||
data: trendData.value.map(d => d.cache_tokens),
|
||||
borderColor: chartColors.value.cache,
|
||||
backgroundColor: `${chartColors.value.cache}20`,
|
||||
fill: true,
|
||||
tension: 0.3,
|
||||
},
|
||||
],
|
||||
}
|
||||
})
|
||||
|
||||
// User trend chart data
|
||||
const userTrendChartData = computed(() => {
|
||||
if (!userTrend.value.length) return null
|
||||
|
||||
// Group by user
|
||||
const userGroups = new Map<string, { name: string; data: Map<string, number> }>()
|
||||
const allDates = new Set<string>()
|
||||
|
||||
userTrend.value.forEach(point => {
|
||||
allDates.add(point.date)
|
||||
const key = point.username || `User #${point.user_id}`
|
||||
if (!userGroups.has(key)) {
|
||||
userGroups.set(key, { name: key, data: new Map() })
|
||||
}
|
||||
userGroups.get(key)!.data.set(point.date, point.tokens)
|
||||
})
|
||||
|
||||
const sortedDates = Array.from(allDates).sort()
|
||||
const colors = ['#3b82f6', '#10b981', '#f59e0b', '#ef4444', '#8b5cf6', '#ec4899', '#14b8a6', '#f97316', '#6366f1', '#84cc16', '#06b6d4', '#a855f7']
|
||||
|
||||
const datasets = Array.from(userGroups.values()).map((group, idx) => ({
|
||||
label: group.name,
|
||||
data: sortedDates.map(date => group.data.get(date) || 0),
|
||||
borderColor: colors[idx % colors.length],
|
||||
backgroundColor: `${colors[idx % colors.length]}20`,
|
||||
fill: false,
|
||||
tension: 0.3,
|
||||
}))
|
||||
|
||||
return {
|
||||
labels: sortedDates,
|
||||
datasets,
|
||||
}
|
||||
})
|
||||
|
||||
// Format helpers
|
||||
const formatTokens = (value: number): string => {
|
||||
if (value >= 1_000_000_000) {
|
||||
return `${(value / 1_000_000_000).toFixed(2)}B`
|
||||
} else if (value >= 1_000_000) {
|
||||
return `${(value / 1_000_000).toFixed(2)}M`
|
||||
} else if (value >= 1_000) {
|
||||
return `${(value / 1_000).toFixed(2)}K`
|
||||
}
|
||||
return value.toLocaleString()
|
||||
}
|
||||
|
||||
const formatNumber = (value: number): string => {
|
||||
return value.toLocaleString()
|
||||
}
|
||||
|
||||
const formatCost = (value: number): string => {
|
||||
if (value >= 1000) {
|
||||
return (value / 1000).toFixed(2) + 'K'
|
||||
} else if (value >= 1) {
|
||||
return value.toFixed(2)
|
||||
} else if (value >= 0.01) {
|
||||
return value.toFixed(3)
|
||||
}
|
||||
return value.toFixed(4)
|
||||
}
|
||||
|
||||
const formatDuration = (ms: number): string => {
|
||||
if (ms >= 1000) {
|
||||
return `${(ms / 1000).toFixed(2)}s`
|
||||
}
|
||||
return `${Math.round(ms)}ms`
|
||||
}
|
||||
|
||||
// Date range change handler
|
||||
const onDateRangeChange = (range: { startDate: string; endDate: string; preset: string | null }) => {
|
||||
// Auto-select granularity based on date range
|
||||
const start = new Date(range.startDate)
|
||||
const end = new Date(range.endDate)
|
||||
const daysDiff = Math.ceil((end.getTime() - start.getTime()) / (1000 * 60 * 60 * 24))
|
||||
|
||||
// If range is 1 day, use hourly granularity
|
||||
if (daysDiff <= 1) {
|
||||
granularity.value = 'hour'
|
||||
} else {
|
||||
granularity.value = 'day'
|
||||
}
|
||||
|
||||
loadChartData()
|
||||
}
|
||||
|
||||
// Initialize default date range
|
||||
const initializeDateRange = () => {
|
||||
const now = new Date()
|
||||
const today = now.toISOString().split('T')[0]
|
||||
const weekAgo = new Date(now)
|
||||
weekAgo.setDate(weekAgo.getDate() - 6)
|
||||
|
||||
startDate.value = weekAgo.toISOString().split('T')[0]
|
||||
endDate.value = today
|
||||
granularity.value = 'day'
|
||||
}
|
||||
|
||||
// Load data
|
||||
const loadDashboardStats = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
stats.value = await adminAPI.dashboard.getStats()
|
||||
} catch (error) {
|
||||
appStore.showError(t('admin.dashboard.failedToLoad'))
|
||||
console.error('Error loading dashboard stats:', error)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const loadChartData = async () => {
|
||||
try {
|
||||
const params = {
|
||||
start_date: startDate.value,
|
||||
end_date: endDate.value,
|
||||
granularity: granularity.value,
|
||||
}
|
||||
|
||||
const [trendResponse, modelResponse, userResponse] = await Promise.all([
|
||||
adminAPI.dashboard.getUsageTrend(params),
|
||||
adminAPI.dashboard.getModelStats({ start_date: startDate.value, end_date: endDate.value }),
|
||||
adminAPI.dashboard.getUserUsageTrend({ ...params, limit: 12 }),
|
||||
])
|
||||
|
||||
trendData.value = trendResponse.trend || []
|
||||
modelStats.value = modelResponse.models || []
|
||||
userTrend.value = userResponse.trend || []
|
||||
} catch (error) {
|
||||
console.error('Error loading chart data:', error)
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadDashboardStats()
|
||||
initializeDateRange()
|
||||
loadChartData()
|
||||
})
|
||||
|
||||
// Watch for dark mode changes
|
||||
watch(isDarkMode, () => {
|
||||
// Force chart re-render on theme change
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* Compact Select styling for dashboard */
|
||||
:deep(.select-trigger) {
|
||||
@apply px-3 py-1.5 text-sm rounded-lg;
|
||||
}
|
||||
|
||||
:deep(.select-dropdown) {
|
||||
@apply rounded-lg;
|
||||
}
|
||||
|
||||
:deep(.select-option) {
|
||||
@apply px-3 py-2 text-sm;
|
||||
}
|
||||
</style>
|
||||
695
frontend/src/views/admin/GroupsView.vue
Normal file
695
frontend/src/views/admin/GroupsView.vue
Normal file
@@ -0,0 +1,695 @@
|
||||
<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('admin.groups.createGroup') }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Filters -->
|
||||
<div class="flex flex-wrap gap-3">
|
||||
<Select
|
||||
v-model="filters.platform"
|
||||
:options="platformFilterOptions"
|
||||
placeholder="All Platforms"
|
||||
class="w-44"
|
||||
@change="loadGroups"
|
||||
/>
|
||||
<Select
|
||||
v-model="filters.status"
|
||||
:options="statusOptions"
|
||||
placeholder="All Status"
|
||||
class="w-40"
|
||||
@change="loadGroups"
|
||||
/>
|
||||
<Select
|
||||
v-model="filters.is_exclusive"
|
||||
:options="exclusiveOptions"
|
||||
placeholder="All Groups"
|
||||
class="w-44"
|
||||
@change="loadGroups"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Groups Table -->
|
||||
<div class="card overflow-hidden">
|
||||
<DataTable :columns="columns" :data="groups" :loading="loading">
|
||||
<template #cell-name="{ value }">
|
||||
<span class="font-medium text-gray-900 dark:text-white">{{ value }}</span>
|
||||
</template>
|
||||
|
||||
<template #cell-platform="{ value }">
|
||||
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-purple-100 text-purple-800 dark:bg-purple-900/30 dark:text-purple-400">
|
||||
<svg class="w-3 h-3 mr-1" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-1 17.93c-3.95-.49-7-3.85-7-7.93 0-.62.08-1.21.21-1.79L9 15v1c0 1.1.9 2 2 2v1.93zm6.9-2.54c-.26-.81-1-1.39-1.9-1.39h-1v-3c0-.55-.45-1-1-1H8v-2h2c.55 0 1-.45 1-1V7h2c1.1 0 2-.9 2-2v-.41c2.93 1.19 5 4.06 5 7.41 0 2.08-.8 3.97-2.1 5.39z"/>
|
||||
</svg>
|
||||
{{ value.charAt(0).toUpperCase() + value.slice(1) }}
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<template #cell-rate_multiplier="{ value }">
|
||||
<span class="text-sm text-gray-700 dark:text-gray-300">{{ value }}x</span>
|
||||
</template>
|
||||
|
||||
<template #cell-is_exclusive="{ value }">
|
||||
<span
|
||||
:class="[
|
||||
'badge',
|
||||
value ? 'badge-primary' : 'badge-gray'
|
||||
]"
|
||||
>
|
||||
{{ value ? t('admin.groups.exclusive') : t('admin.groups.public') }}
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<template #cell-account_count="{ value }">
|
||||
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-gray-100 text-gray-800 dark:bg-dark-600 dark:text-gray-300">
|
||||
{{ t('admin.groups.accountsCount', { count: value || 0 }) }}
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<template #cell-status="{ value }">
|
||||
<span
|
||||
:class="[
|
||||
'badge',
|
||||
value === 'active' ? 'badge-success' : 'badge-danger'
|
||||
]"
|
||||
>
|
||||
{{ value }}
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<template #cell-actions="{ row }">
|
||||
<div class="flex items-center gap-1">
|
||||
<button
|
||||
@click="handleEdit(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="t('common.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>
|
||||
<button
|
||||
@click="handleDelete(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="t('common.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('admin.groups.noGroupsYet')"
|
||||
:description="t('admin.groups.createFirstGroup')"
|
||||
:action-text="t('admin.groups.createGroup')"
|
||||
@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 Group Modal -->
|
||||
<Modal
|
||||
:show="showCreateModal"
|
||||
:title="t('admin.groups.createGroup')"
|
||||
size="lg"
|
||||
@close="closeCreateModal"
|
||||
>
|
||||
<form @submit.prevent="handleCreateGroup" class="space-y-5">
|
||||
<div>
|
||||
<label class="input-label">{{ t('admin.groups.form.name') }}</label>
|
||||
<input
|
||||
v-model="createForm.name"
|
||||
type="text"
|
||||
required
|
||||
class="input"
|
||||
:placeholder="t('admin.groups.enterGroupName')"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="input-label">{{ t('admin.groups.form.description') }}</label>
|
||||
<textarea
|
||||
v-model="createForm.description"
|
||||
rows="3"
|
||||
class="input"
|
||||
:placeholder="t('admin.groups.optionalDescription')"
|
||||
></textarea>
|
||||
</div>
|
||||
<div>
|
||||
<label class="input-label">{{ t('admin.groups.form.platform') }}</label>
|
||||
<Select
|
||||
v-model="createForm.platform"
|
||||
:options="platformOptions"
|
||||
/>
|
||||
<p class="input-hint">{{ t('admin.groups.platformHint') }}</p>
|
||||
</div>
|
||||
<div v-if="createForm.subscription_type !== 'subscription'">
|
||||
<label class="input-label">{{ t('admin.groups.form.rateMultiplier') }}</label>
|
||||
<input
|
||||
v-model.number="createForm.rate_multiplier"
|
||||
type="number"
|
||||
step="0.1"
|
||||
min="0.1"
|
||||
required
|
||||
class="input"
|
||||
/>
|
||||
<p class="input-hint">{{ t('admin.groups.rateMultiplierHint') }}</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
@click="createForm.is_exclusive = !createForm.is_exclusive"
|
||||
:class="[
|
||||
'relative inline-flex h-6 w-11 items-center rounded-full transition-colors',
|
||||
createForm.is_exclusive ? 'bg-primary-500' : 'bg-gray-300 dark:bg-dark-600'
|
||||
]"
|
||||
>
|
||||
<span
|
||||
:class="[
|
||||
'inline-block h-4 w-4 transform rounded-full bg-white transition-transform shadow',
|
||||
createForm.is_exclusive ? 'translate-x-6' : 'translate-x-1'
|
||||
]"
|
||||
/>
|
||||
</button>
|
||||
<label class="text-sm text-gray-700 dark:text-gray-300">
|
||||
{{ t('admin.groups.exclusiveHint') }}
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<!-- Subscription Configuration -->
|
||||
<div class="border-t pt-4 mt-4">
|
||||
<h4 class="text-sm font-medium text-gray-900 dark:text-white mb-4">{{ t('admin.groups.subscription.title') }}</h4>
|
||||
|
||||
<div class="mb-4">
|
||||
<label class="input-label">{{ t('admin.groups.subscription.type') }}</label>
|
||||
<Select
|
||||
v-model="createForm.subscription_type"
|
||||
:options="subscriptionTypeOptions"
|
||||
/>
|
||||
<p class="input-hint">{{ t('admin.groups.subscription.typeHint') }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Subscription limits (only show when subscription type is selected) -->
|
||||
<div v-if="createForm.subscription_type === 'subscription'" class="space-y-4 pl-4 border-l-2 border-primary-200 dark:border-primary-800">
|
||||
<div>
|
||||
<label class="input-label">{{ t('admin.groups.subscription.dailyLimit') }}</label>
|
||||
<input
|
||||
v-model.number="createForm.daily_limit_usd"
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
class="input"
|
||||
:placeholder="t('admin.groups.subscription.noLimit')"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="input-label">{{ t('admin.groups.subscription.weeklyLimit') }}</label>
|
||||
<input
|
||||
v-model.number="createForm.weekly_limit_usd"
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
class="input"
|
||||
:placeholder="t('admin.groups.subscription.noLimit')"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="input-label">{{ t('admin.groups.subscription.monthlyLimit') }}</label>
|
||||
<input
|
||||
v-model.number="createForm.monthly_limit_usd"
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
class="input"
|
||||
:placeholder="t('admin.groups.subscription.noLimit')"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</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="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('admin.groups.creating') : t('common.create') }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</Modal>
|
||||
|
||||
<!-- Edit Group Modal -->
|
||||
<Modal
|
||||
:show="showEditModal"
|
||||
:title="t('admin.groups.editGroup')"
|
||||
size="lg"
|
||||
@close="closeEditModal"
|
||||
>
|
||||
<form v-if="editingGroup" @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"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="input-label">{{ t('admin.groups.form.description') }}</label>
|
||||
<textarea
|
||||
v-model="editForm.description"
|
||||
rows="3"
|
||||
class="input"
|
||||
></textarea>
|
||||
</div>
|
||||
<div>
|
||||
<label class="input-label">{{ t('admin.groups.form.platform') }}</label>
|
||||
<Select
|
||||
v-model="editForm.platform"
|
||||
:options="platformOptions"
|
||||
/>
|
||||
</div>
|
||||
<div v-if="editForm.subscription_type !== 'subscription'">
|
||||
<label class="input-label">{{ t('admin.groups.form.rateMultiplier') }}</label>
|
||||
<input
|
||||
v-model.number="editForm.rate_multiplier"
|
||||
type="number"
|
||||
step="0.1"
|
||||
min="0.1"
|
||||
required
|
||||
class="input"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
@click="editForm.is_exclusive = !editForm.is_exclusive"
|
||||
:class="[
|
||||
'relative inline-flex h-6 w-11 items-center rounded-full transition-colors',
|
||||
editForm.is_exclusive ? 'bg-primary-500' : 'bg-gray-300 dark:bg-dark-600'
|
||||
]"
|
||||
>
|
||||
<span
|
||||
:class="[
|
||||
'inline-block h-4 w-4 transform rounded-full bg-white transition-transform shadow',
|
||||
editForm.is_exclusive ? 'translate-x-6' : 'translate-x-1'
|
||||
]"
|
||||
/>
|
||||
</button>
|
||||
<label class="text-sm text-gray-700 dark:text-gray-300">
|
||||
{{ t('admin.groups.exclusiveHint') }}
|
||||
</label>
|
||||
</div>
|
||||
<div>
|
||||
<label class="input-label">{{ t('admin.groups.form.status') }}</label>
|
||||
<Select
|
||||
v-model="editForm.status"
|
||||
:options="editStatusOptions"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Subscription Configuration -->
|
||||
<div class="border-t pt-4 mt-4">
|
||||
<h4 class="text-sm font-medium text-gray-900 dark:text-white mb-4">{{ t('admin.groups.subscription.title') }}</h4>
|
||||
|
||||
<div class="mb-4">
|
||||
<label class="input-label">{{ t('admin.groups.subscription.type') }}</label>
|
||||
<Select
|
||||
v-model="editForm.subscription_type"
|
||||
:options="subscriptionTypeOptions"
|
||||
/>
|
||||
<p class="input-hint">{{ t('admin.groups.subscription.typeHint') }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Subscription limits (only show when subscription type is selected) -->
|
||||
<div v-if="editForm.subscription_type === 'subscription'" class="space-y-4 pl-4 border-l-2 border-primary-200 dark:border-primary-800">
|
||||
<div>
|
||||
<label class="input-label">{{ t('admin.groups.subscription.dailyLimit') }}</label>
|
||||
<input
|
||||
v-model.number="editForm.daily_limit_usd"
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
class="input"
|
||||
:placeholder="t('admin.groups.subscription.noLimit')"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="input-label">{{ t('admin.groups.subscription.weeklyLimit') }}</label>
|
||||
<input
|
||||
v-model.number="editForm.weekly_limit_usd"
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
class="input"
|
||||
:placeholder="t('admin.groups.subscription.noLimit')"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="input-label">{{ t('admin.groups.subscription.monthlyLimit') }}</label>
|
||||
<input
|
||||
v-model.number="editForm.monthly_limit_usd"
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
class="input"
|
||||
:placeholder="t('admin.groups.subscription.noLimit')"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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"
|
||||
>
|
||||
<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('admin.groups.updating') : t('common.update') }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</Modal>
|
||||
|
||||
<!-- Delete Confirmation Dialog -->
|
||||
<ConfirmDialog
|
||||
:show="showDeleteDialog"
|
||||
:title="t('admin.groups.deleteGroup')"
|
||||
:message="deleteConfirmMessage"
|
||||
:confirm-text="t('common.delete')"
|
||||
:cancel-text="t('common.cancel')"
|
||||
:danger="true"
|
||||
@confirm="confirmDelete"
|
||||
@cancel="showDeleteDialog = false"
|
||||
/>
|
||||
</AppLayout>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, computed, onMounted, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
import { adminAPI } from '@/api/admin'
|
||||
import type { Group, GroupPlatform, SubscriptionType } from '@/types'
|
||||
import type { Column } from '@/components/common/DataTable.vue'
|
||||
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'
|
||||
|
||||
const { t } = useI18n()
|
||||
const appStore = useAppStore()
|
||||
|
||||
const columns = computed<Column[]>(() => [
|
||||
{ key: 'name', label: t('admin.groups.columns.name'), sortable: true },
|
||||
{ key: 'platform', label: t('admin.groups.columns.platform'), sortable: true },
|
||||
{ key: 'rate_multiplier', label: t('admin.groups.columns.rateMultiplier'), sortable: true },
|
||||
{ key: 'is_exclusive', label: t('admin.groups.columns.type'), sortable: true },
|
||||
{ key: 'account_count', label: t('admin.groups.columns.accounts'), sortable: true },
|
||||
{ key: 'status', label: t('admin.groups.columns.status'), sortable: true },
|
||||
{ key: 'actions', label: t('admin.groups.columns.actions'), sortable: false }
|
||||
])
|
||||
|
||||
// Filter options
|
||||
const statusOptions = computed(() => [
|
||||
{ value: '', label: t('admin.groups.allStatus') },
|
||||
{ value: 'active', label: t('common.active') },
|
||||
{ value: 'inactive', label: t('common.inactive') }
|
||||
])
|
||||
|
||||
const exclusiveOptions = computed(() => [
|
||||
{ value: '', label: t('admin.groups.allGroups') },
|
||||
{ value: 'true', label: t('admin.groups.exclusive') },
|
||||
{ value: 'false', label: t('admin.groups.nonExclusive') }
|
||||
])
|
||||
|
||||
const platformOptions = computed(() => [
|
||||
{ value: 'anthropic', label: 'Anthropic' }
|
||||
// Future: { value: 'openai', label: 'OpenAI' },
|
||||
// Future: { value: 'gemini', label: 'Gemini' }
|
||||
])
|
||||
|
||||
const platformFilterOptions = computed(() => [
|
||||
{ value: '', label: t('admin.groups.allPlatforms') },
|
||||
{ value: 'anthropic', label: 'Anthropic' }
|
||||
])
|
||||
|
||||
const editStatusOptions = computed(() => [
|
||||
{ value: 'active', label: t('common.active') },
|
||||
{ value: 'inactive', label: t('common.inactive') }
|
||||
])
|
||||
|
||||
const subscriptionTypeOptions = computed(() => [
|
||||
{ value: 'standard', label: t('admin.groups.subscription.standard') },
|
||||
{ value: 'subscription', label: t('admin.groups.subscription.subscription') }
|
||||
])
|
||||
|
||||
const groups = ref<Group[]>([])
|
||||
const loading = ref(false)
|
||||
const filters = reactive({
|
||||
platform: '',
|
||||
status: '',
|
||||
is_exclusive: ''
|
||||
})
|
||||
const pagination = reactive({
|
||||
page: 1,
|
||||
page_size: 20,
|
||||
total: 0,
|
||||
pages: 0
|
||||
})
|
||||
|
||||
const showCreateModal = ref(false)
|
||||
const showEditModal = ref(false)
|
||||
const showDeleteDialog = ref(false)
|
||||
const submitting = ref(false)
|
||||
const editingGroup = ref<Group | null>(null)
|
||||
const deletingGroup = ref<Group | null>(null)
|
||||
|
||||
const createForm = reactive({
|
||||
name: '',
|
||||
description: '',
|
||||
platform: 'anthropic' as GroupPlatform,
|
||||
rate_multiplier: 1.0,
|
||||
is_exclusive: false,
|
||||
subscription_type: 'standard' as SubscriptionType,
|
||||
daily_limit_usd: null as number | null,
|
||||
weekly_limit_usd: null as number | null,
|
||||
monthly_limit_usd: null as number | null
|
||||
})
|
||||
|
||||
const editForm = reactive({
|
||||
name: '',
|
||||
description: '',
|
||||
platform: 'anthropic' as GroupPlatform,
|
||||
rate_multiplier: 1.0,
|
||||
is_exclusive: false,
|
||||
status: 'active' as 'active' | 'inactive',
|
||||
subscription_type: 'standard' as SubscriptionType,
|
||||
daily_limit_usd: null as number | null,
|
||||
weekly_limit_usd: null as number | null,
|
||||
monthly_limit_usd: null as number | null
|
||||
})
|
||||
|
||||
// 根据分组类型返回不同的删除确认消息
|
||||
const deleteConfirmMessage = computed(() => {
|
||||
if (!deletingGroup.value) {
|
||||
return ''
|
||||
}
|
||||
if (deletingGroup.value.subscription_type === 'subscription') {
|
||||
return t('admin.groups.deleteConfirmSubscription', { name: deletingGroup.value.name })
|
||||
}
|
||||
return t('admin.groups.deleteConfirm', { name: deletingGroup.value.name })
|
||||
})
|
||||
|
||||
const loadGroups = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const response = await adminAPI.groups.list(
|
||||
pagination.page,
|
||||
pagination.page_size,
|
||||
{
|
||||
platform: filters.platform || undefined,
|
||||
status: filters.status as any,
|
||||
is_exclusive: filters.is_exclusive ? filters.is_exclusive === 'true' : undefined
|
||||
}
|
||||
)
|
||||
groups.value = response.items
|
||||
pagination.total = response.total
|
||||
pagination.pages = response.pages
|
||||
} catch (error) {
|
||||
appStore.showError(t('admin.groups.failedToLoad'))
|
||||
console.error('Error loading groups:', error)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handlePageChange = (page: number) => {
|
||||
pagination.page = page
|
||||
loadGroups()
|
||||
}
|
||||
|
||||
const closeCreateModal = () => {
|
||||
showCreateModal.value = false
|
||||
createForm.name = ''
|
||||
createForm.description = ''
|
||||
createForm.platform = 'anthropic'
|
||||
createForm.rate_multiplier = 1.0
|
||||
createForm.is_exclusive = false
|
||||
createForm.subscription_type = 'standard'
|
||||
createForm.daily_limit_usd = null
|
||||
createForm.weekly_limit_usd = null
|
||||
createForm.monthly_limit_usd = null
|
||||
}
|
||||
|
||||
const handleCreateGroup = async () => {
|
||||
submitting.value = true
|
||||
try {
|
||||
await adminAPI.groups.create(createForm)
|
||||
appStore.showSuccess(t('admin.groups.groupCreated'))
|
||||
closeCreateModal()
|
||||
loadGroups()
|
||||
} catch (error: any) {
|
||||
appStore.showError(error.response?.data?.detail || t('admin.groups.failedToCreate'))
|
||||
console.error('Error creating group:', error)
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleEdit = (group: Group) => {
|
||||
editingGroup.value = group
|
||||
editForm.name = group.name
|
||||
editForm.description = group.description || ''
|
||||
editForm.platform = group.platform
|
||||
editForm.rate_multiplier = group.rate_multiplier
|
||||
editForm.is_exclusive = group.is_exclusive
|
||||
editForm.status = group.status
|
||||
editForm.subscription_type = group.subscription_type || 'standard'
|
||||
editForm.daily_limit_usd = group.daily_limit_usd
|
||||
editForm.weekly_limit_usd = group.weekly_limit_usd
|
||||
editForm.monthly_limit_usd = group.monthly_limit_usd
|
||||
showEditModal.value = true
|
||||
}
|
||||
|
||||
const closeEditModal = () => {
|
||||
showEditModal.value = false
|
||||
editingGroup.value = null
|
||||
}
|
||||
|
||||
const handleUpdateGroup = async () => {
|
||||
if (!editingGroup.value) return
|
||||
|
||||
submitting.value = true
|
||||
try {
|
||||
await adminAPI.groups.update(editingGroup.value.id, editForm)
|
||||
appStore.showSuccess(t('admin.groups.groupUpdated'))
|
||||
closeEditModal()
|
||||
loadGroups()
|
||||
} catch (error: any) {
|
||||
appStore.showError(error.response?.data?.detail || t('admin.groups.failedToUpdate'))
|
||||
console.error('Error updating group:', error)
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleDelete = (group: Group) => {
|
||||
deletingGroup.value = group
|
||||
showDeleteDialog.value = true
|
||||
}
|
||||
|
||||
const confirmDelete = async () => {
|
||||
if (!deletingGroup.value) return
|
||||
|
||||
try {
|
||||
await adminAPI.groups.delete(deletingGroup.value.id)
|
||||
appStore.showSuccess(t('admin.groups.groupDeleted'))
|
||||
showDeleteDialog.value = false
|
||||
deletingGroup.value = null
|
||||
loadGroups()
|
||||
} catch (error: any) {
|
||||
appStore.showError(error.response?.data?.detail || t('admin.groups.failedToDelete'))
|
||||
console.error('Error deleting group:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// 监听 subscription_type 变化,配额模式时重置 rate_multiplier 为 1
|
||||
watch(() => createForm.subscription_type, (newVal) => {
|
||||
if (newVal === 'subscription') {
|
||||
createForm.rate_multiplier = 1.0
|
||||
}
|
||||
})
|
||||
|
||||
watch(() => editForm.subscription_type, (newVal) => {
|
||||
if (newVal === 'subscription') {
|
||||
editForm.rate_multiplier = 1.0
|
||||
}
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
loadGroups()
|
||||
})
|
||||
</script>
|
||||
827
frontend/src/views/admin/ProxiesView.vue
Normal file
827
frontend/src/views/admin/ProxiesView.vue
Normal file
@@ -0,0 +1,827 @@
|
||||
<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('admin.proxies.createProxy') }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Search and Filters -->
|
||||
<div class="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
|
||||
<div class="relative flex-1 max-w-md">
|
||||
<svg class="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M21 21l-5.197-5.197m0 0A7.5 7.5 0 105.196 5.196a7.5 7.5 0 0010.607 10.607z" />
|
||||
</svg>
|
||||
<input
|
||||
v-model="searchQuery"
|
||||
type="text"
|
||||
:placeholder="t('admin.proxies.searchProxies')"
|
||||
class="input pl-10"
|
||||
@input="handleSearch"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-3">
|
||||
<Select
|
||||
v-model="filters.protocol"
|
||||
:options="protocolOptions"
|
||||
:placeholder="t('admin.proxies.allProtocols')"
|
||||
class="w-40"
|
||||
@change="loadProxies"
|
||||
/>
|
||||
<Select
|
||||
v-model="filters.status"
|
||||
:options="statusOptions"
|
||||
:placeholder="t('admin.proxies.allStatus')"
|
||||
class="w-36"
|
||||
@change="loadProxies"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Proxies Table -->
|
||||
<div class="card overflow-hidden">
|
||||
<DataTable :columns="columns" :data="proxies" :loading="loading">
|
||||
<template #cell-name="{ value }">
|
||||
<span class="font-medium text-gray-900 dark:text-white">{{ value }}</span>
|
||||
</template>
|
||||
|
||||
<template #cell-protocol="{ value }">
|
||||
<span
|
||||
v-if="value"
|
||||
:class="[
|
||||
'badge',
|
||||
value === 'socks5' ? 'badge-primary' : 'badge-gray'
|
||||
]"
|
||||
>
|
||||
{{ value.toUpperCase() }}
|
||||
</span>
|
||||
<span v-else class="text-sm text-gray-400">-</span>
|
||||
</template>
|
||||
|
||||
<template #cell-address="{ row }">
|
||||
<code class="code text-xs">{{ row.host }}:{{ row.port }}</code>
|
||||
</template>
|
||||
|
||||
<template #cell-status="{ value }">
|
||||
<span
|
||||
:class="[
|
||||
'badge',
|
||||
value === 'active' ? 'badge-success' : 'badge-danger'
|
||||
]"
|
||||
>
|
||||
{{ value }}
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<template #cell-actions="{ row }">
|
||||
<div class="flex items-center gap-1">
|
||||
<button
|
||||
@click="handleTestConnection(row)"
|
||||
:disabled="testingProxyIds.has(row.id)"
|
||||
class="p-2 rounded-lg hover:bg-emerald-50 dark:hover:bg-emerald-900/20 text-gray-500 hover:text-emerald-600 dark:hover:text-emerald-400 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
:title="t('admin.proxies.testConnection')"
|
||||
>
|
||||
<svg v-if="testingProxyIds.has(row.id)" class="w-4 h-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>
|
||||
<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>
|
||||
<button
|
||||
@click="handleEdit(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="t('common.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>
|
||||
<button
|
||||
@click="handleDelete(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="t('common.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('admin.proxies.noProxiesYet')"
|
||||
:description="t('admin.proxies.createFirstProxy')"
|
||||
:action-text="t('admin.proxies.createProxy')"
|
||||
@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 Proxy Modal -->
|
||||
<Modal
|
||||
:show="showCreateModal"
|
||||
:title="t('admin.proxies.createProxy')"
|
||||
size="lg"
|
||||
@close="closeCreateModal"
|
||||
>
|
||||
<!-- Tab Switch -->
|
||||
<div class="flex mb-6 border-b border-gray-200 dark:border-dark-600">
|
||||
<button
|
||||
type="button"
|
||||
@click="createMode = 'standard'"
|
||||
:class="[
|
||||
'px-4 py-2 text-sm font-medium border-b-2 -mb-px transition-colors',
|
||||
createMode === 'standard'
|
||||
? 'border-primary-500 text-primary-600 dark:text-primary-400'
|
||||
: 'border-transparent text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300'
|
||||
]"
|
||||
>
|
||||
<svg class="w-4 h-4 inline mr-1.5" 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('admin.proxies.standardAdd') }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@click="createMode = 'batch'"
|
||||
:class="[
|
||||
'px-4 py-2 text-sm font-medium border-b-2 -mb-px transition-colors',
|
||||
createMode === 'batch'
|
||||
? 'border-primary-500 text-primary-600 dark:text-primary-400'
|
||||
: 'border-transparent text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300'
|
||||
]"
|
||||
>
|
||||
<svg class="w-4 h-4 inline mr-1.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M3.75 12h16.5m-16.5 3.75h16.5M3.75 19.5h16.5M5.625 4.5h12.75a1.875 1.875 0 010 3.75H5.625a1.875 1.875 0 010-3.75z" />
|
||||
</svg>
|
||||
{{ t('admin.proxies.batchAdd') }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Standard Add Form -->
|
||||
<form v-if="createMode === 'standard'" @submit.prevent="handleCreateProxy" class="space-y-5">
|
||||
<div>
|
||||
<label class="input-label">{{ t('admin.proxies.name') }}</label>
|
||||
<input
|
||||
v-model="createForm.name"
|
||||
type="text"
|
||||
required
|
||||
class="input"
|
||||
:placeholder="t('admin.proxies.enterProxyName')"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="input-label">{{ t('admin.proxies.protocol') }}</label>
|
||||
<Select
|
||||
v-model="createForm.protocol"
|
||||
:options="protocolSelectOptions"
|
||||
/>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="input-label">{{ t('admin.proxies.host') }}</label>
|
||||
<input
|
||||
v-model="createForm.host"
|
||||
type="text"
|
||||
required
|
||||
placeholder="proxy.example.com"
|
||||
class="input"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="input-label">{{ t('admin.proxies.port') }}</label>
|
||||
<input
|
||||
v-model.number="createForm.port"
|
||||
type="number"
|
||||
required
|
||||
min="1"
|
||||
max="65535"
|
||||
placeholder="8080"
|
||||
class="input"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label class="input-label">{{ t('admin.proxies.username') }}</label>
|
||||
<input
|
||||
v-model="createForm.username"
|
||||
type="text"
|
||||
class="input"
|
||||
:placeholder="t('admin.proxies.optionalAuth')"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="input-label">{{ t('admin.proxies.password') }}</label>
|
||||
<input
|
||||
v-model="createForm.password"
|
||||
type="password"
|
||||
class="input"
|
||||
:placeholder="t('admin.proxies.optionalAuth')"
|
||||
/>
|
||||
</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="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('admin.proxies.creating') : t('common.create') }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<!-- Batch Add Form -->
|
||||
<div v-else class="space-y-5">
|
||||
<div>
|
||||
<label class="input-label">{{ t('admin.proxies.batchInput') }}</label>
|
||||
<textarea
|
||||
v-model="batchInput"
|
||||
rows="10"
|
||||
class="input font-mono text-sm"
|
||||
:placeholder="t('admin.proxies.batchInputPlaceholder')"
|
||||
@input="parseBatchInput"
|
||||
></textarea>
|
||||
<p class="input-hint mt-2">
|
||||
{{ t('admin.proxies.batchInputHint') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Parse Result -->
|
||||
<div v-if="batchParseResult.total > 0" class="rounded-lg p-4 bg-gray-50 dark:bg-dark-700">
|
||||
<div class="flex items-center gap-4 text-sm">
|
||||
<div class="flex items-center gap-1.5">
|
||||
<svg class="w-4 h-4 text-primary-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<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-gray-700 dark:text-gray-300">
|
||||
{{ t('admin.proxies.parsedCount', { count: batchParseResult.valid }) }}
|
||||
</span>
|
||||
</div>
|
||||
<div v-if="batchParseResult.invalid > 0" class="flex items-center gap-1.5">
|
||||
<svg class="w-4 h-4 text-amber-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 9v3.75m9-.75a9 9 0 11-18 0 9 9 0 0118 0zm-9 3.75h.008v.008H12v-.008z" />
|
||||
</svg>
|
||||
<span class="text-amber-600 dark:text-amber-400">
|
||||
{{ t('admin.proxies.invalidCount', { count: batchParseResult.invalid }) }}
|
||||
</span>
|
||||
</div>
|
||||
<div v-if="batchParseResult.duplicate > 0" class="flex items-center gap-1.5">
|
||||
<svg class="w-4 h-4 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M15.75 17.25v3.375c0 .621-.504 1.125-1.125 1.125h-9.75a1.125 1.125 0 01-1.125-1.125V7.875c0-.621.504-1.125 1.125-1.125H6.75a9.06 9.06 0 011.5.124m7.5 10.376h3.375c.621 0 1.125-.504 1.125-1.125V11.25c0-4.46-3.243-8.161-7.5-8.876a9.06 9.06 0 00-1.5-.124H9.375c-.621 0-1.125.504-1.125 1.125v3.5m7.5 10.375H9.375a1.125 1.125 0 01-1.125-1.125v-9.25m12 6.625v-1.875a3.375 3.375 0 00-3.375-3.375h-1.5a1.125 1.125 0 01-1.125-1.125v-1.5a3.375 3.375 0 00-3.375-3.375H9.75" />
|
||||
</svg>
|
||||
<span class="text-gray-500 dark:text-gray-400">
|
||||
{{ t('admin.proxies.duplicateCount', { count: batchParseResult.duplicate }) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end gap-3 pt-4">
|
||||
<button
|
||||
@click="closeCreateModal"
|
||||
type="button"
|
||||
class="btn btn-secondary"
|
||||
>
|
||||
{{ t('common.cancel') }}
|
||||
</button>
|
||||
<button
|
||||
@click="handleBatchCreate"
|
||||
type="button"
|
||||
:disabled="submitting || batchParseResult.valid === 0"
|
||||
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('admin.proxies.importing') : t('admin.proxies.importProxies', { count: batchParseResult.valid }) }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
<!-- Edit Proxy Modal -->
|
||||
<Modal
|
||||
:show="showEditModal"
|
||||
:title="t('admin.proxies.editProxy')"
|
||||
size="lg"
|
||||
@close="closeEditModal"
|
||||
>
|
||||
<form v-if="editingProxy" @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"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="input-label">{{ t('admin.proxies.protocol') }}</label>
|
||||
<Select
|
||||
v-model="editForm.protocol"
|
||||
:options="protocolSelectOptions"
|
||||
/>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="input-label">{{ t('admin.proxies.host') }}</label>
|
||||
<input
|
||||
v-model="editForm.host"
|
||||
type="text"
|
||||
required
|
||||
class="input"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="input-label">{{ t('admin.proxies.port') }}</label>
|
||||
<input
|
||||
v-model.number="editForm.port"
|
||||
type="number"
|
||||
required
|
||||
min="1"
|
||||
max="65535"
|
||||
class="input"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label class="input-label">{{ t('admin.proxies.username') }}</label>
|
||||
<input
|
||||
v-model="editForm.username"
|
||||
type="text"
|
||||
class="input"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="input-label">{{ t('admin.proxies.password') }}</label>
|
||||
<input
|
||||
v-model="editForm.password"
|
||||
type="password"
|
||||
:placeholder="t('admin.proxies.leaveEmptyToKeep')"
|
||||
class="input"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="input-label">{{ t('admin.proxies.status') }}</label>
|
||||
<Select
|
||||
v-model="editForm.status"
|
||||
:options="editStatusOptions"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<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"
|
||||
>
|
||||
<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('admin.proxies.updating') : t('common.update') }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</Modal>
|
||||
|
||||
<!-- Delete Confirmation Dialog -->
|
||||
<ConfirmDialog
|
||||
:show="showDeleteDialog"
|
||||
:title="t('admin.proxies.deleteProxy')"
|
||||
:message="t('admin.proxies.deleteConfirm', { name: deletingProxy?.name })"
|
||||
:confirm-text="t('common.delete')"
|
||||
:cancel-text="t('common.cancel')"
|
||||
:danger="true"
|
||||
@confirm="confirmDelete"
|
||||
@cancel="showDeleteDialog = false"
|
||||
/>
|
||||
</AppLayout>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, computed, onMounted } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
import { adminAPI } from '@/api/admin'
|
||||
import type { Proxy, ProxyProtocol } from '@/types'
|
||||
import type { Column } from '@/components/common/DataTable.vue'
|
||||
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'
|
||||
|
||||
const { t } = useI18n()
|
||||
const appStore = useAppStore()
|
||||
|
||||
const columns = computed<Column[]>(() => [
|
||||
{ key: 'name', label: t('admin.proxies.columns.name'), sortable: true },
|
||||
{ key: 'protocol', label: t('admin.proxies.columns.protocol'), sortable: true },
|
||||
{ key: 'address', label: t('admin.proxies.columns.address'), sortable: false },
|
||||
{ key: 'status', label: t('admin.proxies.columns.status'), sortable: true },
|
||||
{ key: 'actions', label: t('admin.proxies.columns.actions'), sortable: false }
|
||||
])
|
||||
|
||||
// Filter options
|
||||
const protocolOptions = computed(() => [
|
||||
{ value: '', label: t('admin.proxies.allProtocols') },
|
||||
{ value: 'http', label: 'HTTP' },
|
||||
{ value: 'https', label: 'HTTPS' },
|
||||
{ value: 'socks5', label: 'SOCKS5' }
|
||||
])
|
||||
|
||||
const statusOptions = computed(() => [
|
||||
{ value: '', label: t('admin.proxies.allStatus') },
|
||||
{ value: 'active', label: t('common.active') },
|
||||
{ value: 'inactive', label: t('common.inactive') }
|
||||
])
|
||||
|
||||
// Form options
|
||||
const protocolSelectOptions = [
|
||||
{ value: 'http', label: 'HTTP' },
|
||||
{ value: 'https', label: 'HTTPS' },
|
||||
{ value: 'socks5', label: 'SOCKS5' }
|
||||
]
|
||||
|
||||
const editStatusOptions = computed(() => [
|
||||
{ value: 'active', label: t('common.active') },
|
||||
{ value: 'inactive', label: t('common.inactive') }
|
||||
])
|
||||
|
||||
const proxies = ref<Proxy[]>([])
|
||||
const loading = ref(false)
|
||||
const searchQuery = ref('')
|
||||
const filters = reactive({
|
||||
protocol: '',
|
||||
status: ''
|
||||
})
|
||||
const pagination = reactive({
|
||||
page: 1,
|
||||
page_size: 20,
|
||||
total: 0,
|
||||
pages: 0
|
||||
})
|
||||
|
||||
const showCreateModal = ref(false)
|
||||
const showEditModal = ref(false)
|
||||
const showDeleteDialog = ref(false)
|
||||
const submitting = ref(false)
|
||||
const testingProxyIds = ref<Set<number>>(new Set())
|
||||
const editingProxy = ref<Proxy | null>(null)
|
||||
const deletingProxy = ref<Proxy | null>(null)
|
||||
|
||||
// Batch import state
|
||||
const createMode = ref<'standard' | 'batch'>('standard')
|
||||
const batchInput = ref('')
|
||||
const batchParseResult = reactive({
|
||||
total: 0,
|
||||
valid: 0,
|
||||
invalid: 0,
|
||||
duplicate: 0,
|
||||
proxies: [] as Array<{
|
||||
protocol: ProxyProtocol
|
||||
host: string
|
||||
port: number
|
||||
username: string
|
||||
password: string
|
||||
}>
|
||||
})
|
||||
|
||||
const createForm = reactive({
|
||||
name: '',
|
||||
protocol: 'http' as ProxyProtocol,
|
||||
host: '',
|
||||
port: 8080,
|
||||
username: '',
|
||||
password: ''
|
||||
})
|
||||
|
||||
const editForm = reactive({
|
||||
name: '',
|
||||
protocol: 'http' as ProxyProtocol,
|
||||
host: '',
|
||||
port: 8080,
|
||||
username: '',
|
||||
password: '',
|
||||
status: 'active' as 'active' | 'inactive'
|
||||
})
|
||||
|
||||
const loadProxies = async () => {
|
||||
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
|
||||
}
|
||||
)
|
||||
proxies.value = response.items
|
||||
pagination.total = response.total
|
||||
pagination.pages = response.pages
|
||||
} catch (error) {
|
||||
appStore.showError(t('admin.proxies.failedToLoad'))
|
||||
console.error('Error loading proxies:', error)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
let searchTimeout: ReturnType<typeof setTimeout>
|
||||
const handleSearch = () => {
|
||||
clearTimeout(searchTimeout)
|
||||
searchTimeout = setTimeout(() => {
|
||||
pagination.page = 1
|
||||
loadProxies()
|
||||
}, 300)
|
||||
}
|
||||
|
||||
const handlePageChange = (page: number) => {
|
||||
pagination.page = page
|
||||
loadProxies()
|
||||
}
|
||||
|
||||
const closeCreateModal = () => {
|
||||
showCreateModal.value = false
|
||||
createMode.value = 'standard'
|
||||
createForm.name = ''
|
||||
createForm.protocol = 'http'
|
||||
createForm.host = ''
|
||||
createForm.port = 8080
|
||||
createForm.username = ''
|
||||
createForm.password = ''
|
||||
batchInput.value = ''
|
||||
batchParseResult.total = 0
|
||||
batchParseResult.valid = 0
|
||||
batchParseResult.invalid = 0
|
||||
batchParseResult.duplicate = 0
|
||||
batchParseResult.proxies = []
|
||||
}
|
||||
|
||||
// Parse proxy URL: protocol://user:pass@host:port or protocol://host:port
|
||||
const parseProxyUrl = (line: string): {
|
||||
protocol: ProxyProtocol
|
||||
host: string
|
||||
port: number
|
||||
username: string
|
||||
password: string
|
||||
} | null => {
|
||||
const trimmed = line.trim()
|
||||
if (!trimmed) return null
|
||||
|
||||
// Regex to parse proxy URL
|
||||
const regex = /^(https?|socks5):\/\/(?:([^:@]+):([^@]+)@)?([^:]+):(\d+)$/i
|
||||
const match = trimmed.match(regex)
|
||||
|
||||
if (!match) return null
|
||||
|
||||
const [, protocol, username, password, host, port] = match
|
||||
const portNum = parseInt(port, 10)
|
||||
|
||||
if (portNum < 1 || portNum > 65535) return null
|
||||
|
||||
return {
|
||||
protocol: protocol.toLowerCase() as ProxyProtocol,
|
||||
host,
|
||||
port: portNum,
|
||||
username: username || '',
|
||||
password: password || ''
|
||||
}
|
||||
}
|
||||
|
||||
const parseBatchInput = () => {
|
||||
const lines = batchInput.value.split('\n').filter(l => l.trim())
|
||||
const seen = new Set<string>()
|
||||
const proxies: typeof batchParseResult.proxies = []
|
||||
let invalid = 0
|
||||
let duplicate = 0
|
||||
|
||||
for (const line of lines) {
|
||||
const parsed = parseProxyUrl(line)
|
||||
if (!parsed) {
|
||||
invalid++
|
||||
continue
|
||||
}
|
||||
|
||||
// Check for duplicates (same host:port:username:password)
|
||||
const key = `${parsed.host}:${parsed.port}:${parsed.username}:${parsed.password}`
|
||||
if (seen.has(key)) {
|
||||
duplicate++
|
||||
continue
|
||||
}
|
||||
seen.add(key)
|
||||
proxies.push(parsed)
|
||||
}
|
||||
|
||||
batchParseResult.total = lines.length
|
||||
batchParseResult.valid = proxies.length
|
||||
batchParseResult.invalid = invalid
|
||||
batchParseResult.duplicate = duplicate
|
||||
batchParseResult.proxies = proxies
|
||||
}
|
||||
|
||||
const handleBatchCreate = async () => {
|
||||
if (batchParseResult.valid === 0) return
|
||||
|
||||
submitting.value = true
|
||||
try {
|
||||
const result = await adminAPI.proxies.batchCreate(batchParseResult.proxies)
|
||||
const created = result.created || 0
|
||||
const skipped = result.skipped || 0
|
||||
|
||||
if (created > 0) {
|
||||
appStore.showSuccess(t('admin.proxies.batchImportSuccess', { created, skipped }))
|
||||
} else {
|
||||
appStore.showInfo(t('admin.proxies.batchImportAllSkipped', { skipped }))
|
||||
}
|
||||
|
||||
closeCreateModal()
|
||||
loadProxies()
|
||||
} catch (error: any) {
|
||||
appStore.showError(error.response?.data?.detail || t('admin.proxies.failedToImport'))
|
||||
console.error('Error batch creating proxies:', error)
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleCreateProxy = async () => {
|
||||
submitting.value = true
|
||||
try {
|
||||
await adminAPI.proxies.create({
|
||||
...createForm,
|
||||
username: createForm.username || null,
|
||||
password: createForm.password || null
|
||||
})
|
||||
appStore.showSuccess(t('admin.proxies.proxyCreated'))
|
||||
closeCreateModal()
|
||||
loadProxies()
|
||||
} catch (error: any) {
|
||||
appStore.showError(error.response?.data?.detail || t('admin.proxies.failedToCreate'))
|
||||
console.error('Error creating proxy:', error)
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleEdit = (proxy: Proxy) => {
|
||||
editingProxy.value = proxy
|
||||
editForm.name = proxy.name
|
||||
editForm.protocol = proxy.protocol
|
||||
editForm.host = proxy.host
|
||||
editForm.port = proxy.port
|
||||
editForm.username = proxy.username || ''
|
||||
editForm.password = ''
|
||||
editForm.status = proxy.status
|
||||
showEditModal.value = true
|
||||
}
|
||||
|
||||
const closeEditModal = () => {
|
||||
showEditModal.value = false
|
||||
editingProxy.value = null
|
||||
}
|
||||
|
||||
const handleUpdateProxy = async () => {
|
||||
if (!editingProxy.value) return
|
||||
|
||||
submitting.value = true
|
||||
try {
|
||||
const updateData: any = {
|
||||
name: editForm.name,
|
||||
protocol: editForm.protocol,
|
||||
host: editForm.host,
|
||||
port: editForm.port,
|
||||
username: editForm.username || null,
|
||||
status: editForm.status
|
||||
}
|
||||
|
||||
// Only include password if it was changed
|
||||
if (editForm.password) {
|
||||
updateData.password = editForm.password
|
||||
}
|
||||
|
||||
await adminAPI.proxies.update(editingProxy.value.id, updateData)
|
||||
appStore.showSuccess(t('admin.proxies.proxyUpdated'))
|
||||
closeEditModal()
|
||||
loadProxies()
|
||||
} catch (error: any) {
|
||||
appStore.showError(error.response?.data?.detail || t('admin.proxies.failedToUpdate'))
|
||||
console.error('Error updating proxy:', error)
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleTestConnection = async (proxy: Proxy) => {
|
||||
// Create new Set to trigger reactivity
|
||||
testingProxyIds.value = new Set([...testingProxyIds.value, proxy.id])
|
||||
try {
|
||||
const result = await adminAPI.proxies.testProxy(proxy.id)
|
||||
if (result.success) {
|
||||
const message = result.latency_ms
|
||||
? t('admin.proxies.proxyWorkingWithLatency', { latency: result.latency_ms })
|
||||
: t('admin.proxies.proxyWorking')
|
||||
appStore.showSuccess(message)
|
||||
} else {
|
||||
appStore.showError(result.message || t('admin.proxies.proxyTestFailed'))
|
||||
}
|
||||
} catch (error: any) {
|
||||
appStore.showError(error.response?.data?.detail || t('admin.proxies.failedToTest'))
|
||||
console.error('Error testing proxy:', error)
|
||||
} finally {
|
||||
// Create new Set without this proxy id to trigger reactivity
|
||||
const newSet = new Set(testingProxyIds.value)
|
||||
newSet.delete(proxy.id)
|
||||
testingProxyIds.value = newSet
|
||||
}
|
||||
}
|
||||
|
||||
const handleDelete = (proxy: Proxy) => {
|
||||
deletingProxy.value = proxy
|
||||
showDeleteDialog.value = true
|
||||
}
|
||||
|
||||
const confirmDelete = async () => {
|
||||
if (!deletingProxy.value) return
|
||||
|
||||
try {
|
||||
await adminAPI.proxies.delete(deletingProxy.value.id)
|
||||
appStore.showSuccess(t('admin.proxies.proxyDeleted'))
|
||||
showDeleteDialog.value = false
|
||||
deletingProxy.value = null
|
||||
loadProxies()
|
||||
} catch (error: any) {
|
||||
appStore.showError(error.response?.data?.detail || t('admin.proxies.failedToDelete'))
|
||||
console.error('Error deleting proxy:', error)
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadProxies()
|
||||
})
|
||||
</script>
|
||||
645
frontend/src/views/admin/RedeemView.vue
Normal file
645
frontend/src/views/admin/RedeemView.vue
Normal file
@@ -0,0 +1,645 @@
|
||||
<template>
|
||||
<AppLayout>
|
||||
<div class="space-y-6">
|
||||
<!-- Page Header Actions -->
|
||||
<div class="flex justify-end">
|
||||
<button
|
||||
@click="showGenerateDialog = true"
|
||||
class="btn btn-primary"
|
||||
>
|
||||
{{ t('admin.redeem.generateCodes') }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Filters and Actions -->
|
||||
<div class="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div class="flex-1 max-w-md">
|
||||
<input
|
||||
v-model="searchQuery"
|
||||
type="text"
|
||||
:placeholder="t('admin.redeem.searchCodes')"
|
||||
class="input"
|
||||
@input="handleSearch"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<Select
|
||||
v-model="filters.type"
|
||||
:options="filterTypeOptions"
|
||||
class="w-36"
|
||||
@change="loadCodes"
|
||||
/>
|
||||
<Select
|
||||
v-model="filters.status"
|
||||
:options="filterStatusOptions"
|
||||
class="w-36"
|
||||
@change="loadCodes"
|
||||
/>
|
||||
<button
|
||||
@click="handleExportCodes"
|
||||
class="btn btn-secondary"
|
||||
>
|
||||
{{ t('admin.redeem.exportCsv') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Redeem Codes Table -->
|
||||
<div class="card overflow-hidden">
|
||||
<DataTable :columns="columns" :data="codes" :loading="loading">
|
||||
<template #cell-code="{ value }">
|
||||
<div class="flex items-center space-x-2">
|
||||
<code class="text-sm font-mono text-gray-900 dark:text-gray-100">{{ value }}</code>
|
||||
<button
|
||||
@click="copyToClipboard(value)"
|
||||
:class="[
|
||||
'flex items-center transition-colors',
|
||||
copiedCode === value ? 'text-green-500' : 'text-gray-400 hover:text-gray-600 dark:hover:text-gray-300'
|
||||
]"
|
||||
:title="copiedCode === value ? t('admin.redeem.copied') : t('keys.copyToClipboard')"
|
||||
>
|
||||
<svg v-if="copiedCode !== value" class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" />
|
||||
</svg>
|
||||
<svg v-else class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #cell-type="{ value }">
|
||||
<span
|
||||
:class="[
|
||||
'badge',
|
||||
value === 'balance' ? 'badge-success' :
|
||||
value === 'subscription' ? 'badge-warning' : 'badge-primary'
|
||||
]"
|
||||
>
|
||||
{{ value }}
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<template #cell-value="{ value, row }">
|
||||
<span class="text-sm font-medium text-gray-900 dark:text-white">
|
||||
<template v-if="row.type === 'balance'">${{ value.toFixed(2) }}</template>
|
||||
<template v-else-if="row.type === 'subscription'">
|
||||
{{ row.validity_days || 30 }}{{ t('admin.redeem.days') }}
|
||||
<span v-if="row.group" class="text-gray-500 dark:text-gray-400 text-xs ml-1">({{ row.group.name }})</span>
|
||||
</template>
|
||||
<template v-else>{{ value }}</template>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<template #cell-status="{ value }">
|
||||
<span
|
||||
:class="[
|
||||
'badge',
|
||||
value === 'unused' ? 'badge-success' :
|
||||
value === 'used' ? 'badge-gray' :
|
||||
'badge-danger'
|
||||
]"
|
||||
>
|
||||
{{ value }}
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<template #cell-used_by="{ value }">
|
||||
<span class="text-sm text-gray-500 dark:text-dark-400">
|
||||
{{ value ? t('admin.redeem.userPrefix', { id: value }) : '-' }}
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<template #cell-used_at="{ value }">
|
||||
<span class="text-sm text-gray-500 dark:text-dark-400">{{ value ? formatDate(value) : '-' }}</span>
|
||||
</template>
|
||||
|
||||
<template #cell-actions="{ row }">
|
||||
<div class="flex items-center space-x-2">
|
||||
<button
|
||||
v-if="row.status === 'unused'"
|
||||
@click="handleDelete(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="t('common.delete')"
|
||||
>
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<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>
|
||||
</button>
|
||||
<span v-else class="text-gray-400 dark:text-dark-500">-</span>
|
||||
</div>
|
||||
</template>
|
||||
</DataTable>
|
||||
</div>
|
||||
|
||||
<!-- Pagination -->
|
||||
<Pagination
|
||||
v-if="pagination.total > 0"
|
||||
:page="pagination.page"
|
||||
:total="pagination.total"
|
||||
:page-size="pagination.page_size"
|
||||
@update:page="handlePageChange"
|
||||
/>
|
||||
|
||||
<!-- Batch Actions -->
|
||||
<div v-if="filters.status === 'unused'" class="flex justify-end">
|
||||
<button
|
||||
@click="showDeleteUnusedDialog = true"
|
||||
class="btn btn-danger"
|
||||
>
|
||||
{{ t('admin.redeem.deleteAllUnused') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Delete Confirmation Dialog -->
|
||||
<ConfirmDialog
|
||||
:show="showDeleteDialog"
|
||||
:title="t('admin.redeem.deleteCode')"
|
||||
:message="t('admin.redeem.deleteCodeConfirm')"
|
||||
:confirm-text="t('common.delete')"
|
||||
:cancel-text="t('common.cancel')"
|
||||
danger
|
||||
@confirm="confirmDelete"
|
||||
@cancel="showDeleteDialog = false"
|
||||
/>
|
||||
|
||||
<!-- Delete Unused Codes Dialog -->
|
||||
<ConfirmDialog
|
||||
:show="showDeleteUnusedDialog"
|
||||
:title="t('admin.redeem.deleteAllUnused')"
|
||||
:message="t('admin.redeem.deleteAllUnusedConfirm')"
|
||||
:confirm-text="t('admin.redeem.deleteAll')"
|
||||
:cancel-text="t('common.cancel')"
|
||||
danger
|
||||
@confirm="confirmDeleteUnused"
|
||||
@cancel="showDeleteUnusedDialog = false"
|
||||
/>
|
||||
|
||||
<!-- Generate Codes Dialog -->
|
||||
<Teleport to="body">
|
||||
<div
|
||||
v-if="showGenerateDialog"
|
||||
class="fixed inset-0 z-50 flex items-center justify-center"
|
||||
>
|
||||
<div
|
||||
class="fixed inset-0 bg-black/50"
|
||||
@click="showGenerateDialog = false"
|
||||
></div>
|
||||
<div class="relative z-10 w-full max-w-md bg-white dark:bg-dark-800 rounded-xl shadow-xl p-6">
|
||||
<h2 class="text-lg font-semibold text-gray-900 dark:text-white mb-4">{{ t('admin.redeem.generateCodesTitle') }}</h2>
|
||||
<form @submit.prevent="handleGenerateCodes" class="space-y-4">
|
||||
<div>
|
||||
<label class="input-label">{{ t('admin.redeem.codeType') }}</label>
|
||||
<Select
|
||||
v-model="generateForm.type"
|
||||
:options="typeOptions"
|
||||
/>
|
||||
</div>
|
||||
<!-- 余额/并发类型:显示数值输入 -->
|
||||
<div v-if="generateForm.type !== 'subscription'">
|
||||
<label class="input-label">
|
||||
{{ generateForm.type === 'balance' ? t('admin.redeem.amount') : t('admin.redeem.columns.value') }}
|
||||
</label>
|
||||
<input
|
||||
v-model.number="generateForm.value"
|
||||
type="number"
|
||||
:step="generateForm.type === 'balance' ? '0.01' : '1'"
|
||||
:min="generateForm.type === 'balance' ? '0.01' : '1'"
|
||||
required
|
||||
class="input"
|
||||
/>
|
||||
</div>
|
||||
<!-- 订阅类型:显示分组选择和有效天数 -->
|
||||
<template v-if="generateForm.type === 'subscription'">
|
||||
<div>
|
||||
<label class="input-label">{{ t('admin.redeem.selectGroup') }}</label>
|
||||
<Select
|
||||
v-model="generateForm.group_id"
|
||||
:options="subscriptionGroupOptions"
|
||||
:placeholder="t('admin.redeem.selectGroupPlaceholder')"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="input-label">{{ t('admin.redeem.validityDays') }}</label>
|
||||
<input
|
||||
v-model.number="generateForm.validity_days"
|
||||
type="number"
|
||||
min="1"
|
||||
max="365"
|
||||
required
|
||||
class="input"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
<div>
|
||||
<label class="input-label">{{ t('admin.redeem.count') }}</label>
|
||||
<input
|
||||
v-model.number="generateForm.count"
|
||||
type="number"
|
||||
min="1"
|
||||
max="100"
|
||||
required
|
||||
class="input"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex justify-end gap-3 pt-2">
|
||||
<button
|
||||
type="button"
|
||||
@click="showGenerateDialog = false"
|
||||
class="btn btn-secondary"
|
||||
>
|
||||
{{ t('common.cancel') }}
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
:disabled="generating"
|
||||
class="btn btn-primary"
|
||||
>
|
||||
{{ generating ? t('admin.redeem.generating') : t('admin.redeem.generate') }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
|
||||
<!-- Generated Codes Result Dialog -->
|
||||
<Teleport to="body">
|
||||
<div
|
||||
v-if="showResultDialog"
|
||||
class="fixed inset-0 z-50 flex items-center justify-center p-4"
|
||||
>
|
||||
<div
|
||||
class="fixed inset-0 bg-black/50"
|
||||
@click="closeResultDialog"
|
||||
></div>
|
||||
<div class="relative z-10 w-full max-w-lg bg-white dark:bg-dark-800 rounded-xl shadow-xl">
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between px-5 py-4 border-b border-gray-200 dark:border-dark-600">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-10 h-10 rounded-full bg-green-100 dark:bg-green-900/30 flex items-center justify-center">
|
||||
<svg class="w-5 h-5 text-green-600 dark:text-green-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<h2 class="text-base font-semibold text-gray-900 dark:text-white">
|
||||
{{ t('admin.redeem.generatedSuccessfully') }}
|
||||
</h2>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">{{ t('admin.redeem.codesCreated', { count: generatedCodes.length }) }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
@click="closeResultDialog"
|
||||
class="p-1.5 rounded-lg text-gray-400 hover:text-gray-600 hover:bg-gray-100 dark:hover:text-gray-300 dark:hover:bg-dark-700 transition-colors"
|
||||
>
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<!-- Content -->
|
||||
<div class="p-5">
|
||||
<div class="relative">
|
||||
<textarea
|
||||
readonly
|
||||
:value="generatedCodesText"
|
||||
:style="{ height: textareaHeight }"
|
||||
class="w-full p-3 font-mono text-sm bg-gray-50 dark:bg-dark-700 border border-gray-200 dark:border-dark-600 rounded-lg resize-none focus:outline-none text-gray-800 dark:text-gray-200"
|
||||
></textarea>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Footer -->
|
||||
<div class="flex justify-end gap-2 px-5 py-4 border-t border-gray-200 dark:border-dark-600 bg-gray-50 dark:bg-dark-700/50 rounded-b-xl">
|
||||
<button
|
||||
@click="copyGeneratedCodes"
|
||||
:class="[
|
||||
'btn flex items-center gap-2 transition-all',
|
||||
copiedAll ? 'btn-success' : 'btn-secondary'
|
||||
]"
|
||||
>
|
||||
<svg v-if="!copiedAll" class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" />
|
||||
</svg>
|
||||
<svg v-else class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
{{ copiedAll ? t('admin.redeem.copied') : t('admin.redeem.copyAll') }}
|
||||
</button>
|
||||
<button
|
||||
@click="downloadGeneratedCodes"
|
||||
class="btn btn-primary flex items-center gap-2"
|
||||
>
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
|
||||
</svg>
|
||||
{{ t('admin.redeem.download') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
</AppLayout>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, computed, onMounted } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
import { adminAPI } from '@/api/admin'
|
||||
import type { RedeemCode, RedeemCodeType, Group } from '@/types'
|
||||
import type { Column } from '@/components/common/DataTable.vue'
|
||||
import AppLayout from '@/components/layout/AppLayout.vue'
|
||||
import DataTable from '@/components/common/DataTable.vue'
|
||||
import Pagination from '@/components/common/Pagination.vue'
|
||||
import ConfirmDialog from '@/components/common/ConfirmDialog.vue'
|
||||
import Select from '@/components/common/Select.vue'
|
||||
|
||||
const { t } = useI18n()
|
||||
const appStore = useAppStore()
|
||||
|
||||
const showGenerateDialog = ref(false)
|
||||
const showResultDialog = ref(false)
|
||||
const generatedCodes = ref<RedeemCode[]>([])
|
||||
const subscriptionGroups = ref<Group[]>([])
|
||||
|
||||
// 订阅类型分组选项
|
||||
const subscriptionGroupOptions = computed(() => {
|
||||
return subscriptionGroups.value
|
||||
.filter(g => g.subscription_type === 'subscription')
|
||||
.map(g => ({
|
||||
value: g.id,
|
||||
label: g.name
|
||||
}))
|
||||
})
|
||||
|
||||
const generatedCodesText = computed(() => {
|
||||
return generatedCodes.value.map(code => code.code).join('\n')
|
||||
})
|
||||
|
||||
const textareaHeight = computed(() => {
|
||||
const lineCount = generatedCodes.value.length
|
||||
const lineHeight = 24 // approximate line height in px
|
||||
const padding = 24 // top + bottom padding
|
||||
const minHeight = 60
|
||||
const maxHeight = 240
|
||||
const calculatedHeight = Math.min(Math.max(lineCount * lineHeight + padding, minHeight), maxHeight)
|
||||
return `${calculatedHeight}px`
|
||||
})
|
||||
|
||||
const copiedAll = ref(false)
|
||||
|
||||
const closeResultDialog = () => {
|
||||
showResultDialog.value = false
|
||||
generatedCodes.value = []
|
||||
copiedAll.value = false
|
||||
}
|
||||
|
||||
const copyGeneratedCodes = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(generatedCodesText.value)
|
||||
copiedAll.value = true
|
||||
setTimeout(() => {
|
||||
copiedAll.value = false
|
||||
}, 2000)
|
||||
} catch (error) {
|
||||
appStore.showError(t('admin.redeem.failedToCopy'))
|
||||
}
|
||||
}
|
||||
|
||||
const downloadGeneratedCodes = () => {
|
||||
const blob = new Blob([generatedCodesText.value], { type: 'text/plain' })
|
||||
const url = window.URL.createObjectURL(blob)
|
||||
const link = document.createElement('a')
|
||||
link.href = url
|
||||
link.download = `redeem-codes-${new Date().toISOString().split('T')[0]}.txt`
|
||||
document.body.appendChild(link)
|
||||
link.click()
|
||||
document.body.removeChild(link)
|
||||
window.URL.revokeObjectURL(url)
|
||||
}
|
||||
|
||||
const columns = computed<Column[]>(() => [
|
||||
{ key: 'code', label: t('admin.redeem.columns.code') },
|
||||
{ key: 'type', label: t('admin.redeem.columns.type'), sortable: true },
|
||||
{ key: 'value', label: t('admin.redeem.columns.value'), sortable: true },
|
||||
{ key: 'status', label: t('admin.redeem.columns.status'), sortable: true },
|
||||
{ key: 'used_by', label: t('admin.redeem.columns.usedBy') },
|
||||
{ key: 'used_at', label: t('admin.redeem.columns.usedAt'), sortable: true },
|
||||
{ key: 'actions', label: t('admin.redeem.columns.actions') }
|
||||
])
|
||||
|
||||
const typeOptions = computed(() => [
|
||||
{ value: 'balance', label: t('admin.redeem.balance') },
|
||||
{ value: 'concurrency', label: t('admin.redeem.concurrency') },
|
||||
{ value: 'subscription', label: t('admin.redeem.subscription') }
|
||||
])
|
||||
|
||||
const filterTypeOptions = computed(() => [
|
||||
{ value: '', label: t('admin.redeem.allTypes') },
|
||||
{ value: 'balance', label: t('admin.redeem.balance') },
|
||||
{ value: 'concurrency', label: t('admin.redeem.concurrency') },
|
||||
{ value: 'subscription', label: t('admin.redeem.subscription') }
|
||||
])
|
||||
|
||||
const filterStatusOptions = computed(() => [
|
||||
{ value: '', label: t('admin.redeem.allStatus') },
|
||||
{ value: 'unused', label: t('admin.redeem.unused') },
|
||||
{ value: 'used', label: t('admin.redeem.used') }
|
||||
])
|
||||
|
||||
const codes = ref<RedeemCode[]>([])
|
||||
const loading = ref(false)
|
||||
const generating = ref(false)
|
||||
const searchQuery = ref('')
|
||||
const filters = reactive({
|
||||
type: '',
|
||||
status: ''
|
||||
})
|
||||
const pagination = reactive({
|
||||
page: 1,
|
||||
page_size: 20,
|
||||
total: 0,
|
||||
pages: 0
|
||||
})
|
||||
|
||||
const showDeleteDialog = ref(false)
|
||||
const showDeleteUnusedDialog = ref(false)
|
||||
const deletingCode = ref<RedeemCode | null>(null)
|
||||
const copiedCode = ref<string | null>(null)
|
||||
|
||||
const generateForm = reactive({
|
||||
type: 'balance' as RedeemCodeType,
|
||||
value: 10,
|
||||
count: 1,
|
||||
group_id: null as number | null,
|
||||
validity_days: 30
|
||||
})
|
||||
|
||||
const formatDate = (dateString: string): string => {
|
||||
return new Date(dateString).toLocaleDateString()
|
||||
}
|
||||
|
||||
const loadCodes = async () => {
|
||||
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
|
||||
}
|
||||
)
|
||||
codes.value = response.items
|
||||
pagination.total = response.total
|
||||
pagination.pages = response.pages
|
||||
} catch (error) {
|
||||
appStore.showError(t('admin.redeem.failedToLoad'))
|
||||
console.error('Error loading redeem codes:', error)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
let searchTimeout: ReturnType<typeof setTimeout>
|
||||
const handleSearch = () => {
|
||||
clearTimeout(searchTimeout)
|
||||
searchTimeout = setTimeout(() => {
|
||||
pagination.page = 1
|
||||
loadCodes()
|
||||
}, 300)
|
||||
}
|
||||
|
||||
const handlePageChange = (page: number) => {
|
||||
pagination.page = page
|
||||
loadCodes()
|
||||
}
|
||||
|
||||
const handleGenerateCodes = async () => {
|
||||
// 订阅类型必须选择分组
|
||||
if (generateForm.type === 'subscription' && !generateForm.group_id) {
|
||||
appStore.showError(t('admin.redeem.groupRequired'))
|
||||
return
|
||||
}
|
||||
|
||||
generating.value = true
|
||||
try {
|
||||
const result = await adminAPI.redeem.generate(
|
||||
generateForm.count,
|
||||
generateForm.type,
|
||||
generateForm.value,
|
||||
generateForm.type === 'subscription' ? generateForm.group_id : undefined,
|
||||
generateForm.type === 'subscription' ? generateForm.validity_days : undefined
|
||||
)
|
||||
showGenerateDialog.value = false
|
||||
generatedCodes.value = result
|
||||
showResultDialog.value = true
|
||||
// 重置表单
|
||||
generateForm.group_id = null
|
||||
generateForm.validity_days = 30
|
||||
loadCodes()
|
||||
} catch (error: any) {
|
||||
appStore.showError(error.response?.data?.detail || t('admin.redeem.failedToGenerate'))
|
||||
console.error('Error generating codes:', error)
|
||||
} finally {
|
||||
generating.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const copyToClipboard = async (text: string) => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text)
|
||||
copiedCode.value = text
|
||||
setTimeout(() => {
|
||||
copiedCode.value = null
|
||||
}, 2000)
|
||||
} catch (error) {
|
||||
appStore.showError(t('admin.redeem.failedToCopy'))
|
||||
console.error('Error copying to clipboard:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const handleExportCodes = async () => {
|
||||
try {
|
||||
const blob = await adminAPI.redeem.exportCodes({
|
||||
type: filters.type as RedeemCodeType,
|
||||
status: filters.status as any
|
||||
})
|
||||
|
||||
// Create download link
|
||||
const url = window.URL.createObjectURL(blob)
|
||||
const link = document.createElement('a')
|
||||
link.href = url
|
||||
link.download = `redeem-codes-${new Date().toISOString().split('T')[0]}.csv`
|
||||
document.body.appendChild(link)
|
||||
link.click()
|
||||
document.body.removeChild(link)
|
||||
window.URL.revokeObjectURL(url)
|
||||
|
||||
appStore.showSuccess(t('admin.redeem.codesExported'))
|
||||
} catch (error: any) {
|
||||
appStore.showError(error.response?.data?.detail || t('admin.redeem.failedToExport'))
|
||||
console.error('Error exporting codes:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDelete = (code: RedeemCode) => {
|
||||
deletingCode.value = code
|
||||
showDeleteDialog.value = true
|
||||
}
|
||||
|
||||
const confirmDelete = async () => {
|
||||
if (!deletingCode.value) return
|
||||
|
||||
try {
|
||||
await adminAPI.redeem.delete(deletingCode.value.id)
|
||||
appStore.showSuccess(t('admin.redeem.codeDeleted'))
|
||||
showDeleteDialog.value = false
|
||||
deletingCode.value = null
|
||||
loadCodes()
|
||||
} catch (error: any) {
|
||||
appStore.showError(error.response?.data?.detail || t('admin.redeem.failedToDelete'))
|
||||
console.error('Error deleting code:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const confirmDeleteUnused = async () => {
|
||||
try {
|
||||
// Get all unused codes and delete them
|
||||
const unusedCodesResponse = await adminAPI.redeem.list(1, 1000, { status: 'unused' })
|
||||
const unusedCodeIds = unusedCodesResponse.items.map(code => code.id)
|
||||
|
||||
if (unusedCodeIds.length === 0) {
|
||||
appStore.showInfo(t('admin.redeem.noUnusedCodes'))
|
||||
showDeleteUnusedDialog.value = false
|
||||
return
|
||||
}
|
||||
|
||||
const result = await adminAPI.redeem.batchDelete(unusedCodeIds)
|
||||
appStore.showSuccess(t('admin.redeem.codesDeleted', { count: result.deleted }))
|
||||
showDeleteUnusedDialog.value = false
|
||||
loadCodes()
|
||||
} catch (error: any) {
|
||||
appStore.showError(error.response?.data?.detail || t('admin.redeem.failedToDeleteUnused'))
|
||||
console.error('Error deleting unused codes:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// 加载订阅类型分组
|
||||
const loadSubscriptionGroups = async () => {
|
||||
try {
|
||||
const groups = await adminAPI.groups.getAll()
|
||||
subscriptionGroups.value = groups
|
||||
} catch (error) {
|
||||
console.error('Error loading subscription groups:', error)
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadCodes()
|
||||
loadSubscriptionGroups()
|
||||
})
|
||||
</script>
|
||||
559
frontend/src/views/admin/SettingsView.vue
Normal file
559
frontend/src/views/admin/SettingsView.vue
Normal file
@@ -0,0 +1,559 @@
|
||||
<template>
|
||||
<AppLayout>
|
||||
<div class="max-w-4xl mx-auto space-y-6">
|
||||
<!-- Loading State -->
|
||||
<div v-if="loading" class="flex items-center justify-center py-12">
|
||||
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-primary-600"></div>
|
||||
</div>
|
||||
|
||||
<!-- Settings Form -->
|
||||
<form v-else @submit.prevent="saveSettings" class="space-y-6">
|
||||
<!-- Registration Settings -->
|
||||
<div class="card">
|
||||
<div class="px-6 py-4 border-b border-gray-100 dark:border-dark-700">
|
||||
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">{{ t('admin.settings.registration.title') }}</h2>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400 mt-1">{{ t('admin.settings.registration.description') }}</p>
|
||||
</div>
|
||||
<div class="p-6 space-y-5">
|
||||
<!-- Enable Registration -->
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<label class="font-medium text-gray-900 dark:text-white">{{ t('admin.settings.registration.enableRegistration') }}</label>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">{{ t('admin.settings.registration.enableRegistrationHint') }}</p>
|
||||
</div>
|
||||
<Toggle v-model="form.registration_enabled" />
|
||||
</div>
|
||||
|
||||
<!-- Email Verification -->
|
||||
<div class="flex items-center justify-between pt-4 border-t border-gray-100 dark:border-dark-700">
|
||||
<div>
|
||||
<label class="font-medium text-gray-900 dark:text-white">{{ t('admin.settings.registration.emailVerification') }}</label>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">{{ t('admin.settings.registration.emailVerificationHint') }}</p>
|
||||
</div>
|
||||
<Toggle v-model="form.email_verify_enabled" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Cloudflare Turnstile Settings -->
|
||||
<div class="card">
|
||||
<div class="px-6 py-4 border-b border-gray-100 dark:border-dark-700">
|
||||
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">{{ t('admin.settings.turnstile.title') }}</h2>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400 mt-1">{{ t('admin.settings.turnstile.description') }}</p>
|
||||
</div>
|
||||
<div class="p-6 space-y-5">
|
||||
<!-- Enable Turnstile -->
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<label class="font-medium text-gray-900 dark:text-white">{{ t('admin.settings.turnstile.enableTurnstile') }}</label>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">{{ t('admin.settings.turnstile.enableTurnstileHint') }}</p>
|
||||
</div>
|
||||
<Toggle v-model="form.turnstile_enabled" />
|
||||
</div>
|
||||
|
||||
<!-- Turnstile Keys - Only show when enabled -->
|
||||
<div v-if="form.turnstile_enabled" class="pt-4 border-t border-gray-100 dark:border-dark-700">
|
||||
<div class="grid grid-cols-1 gap-6">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
{{ t('admin.settings.turnstile.siteKey') }}
|
||||
</label>
|
||||
<input
|
||||
v-model="form.turnstile_site_key"
|
||||
type="text"
|
||||
class="input font-mono text-sm"
|
||||
placeholder="0x4AAAAAAA..."
|
||||
/>
|
||||
<p class="mt-1.5 text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ t('admin.settings.turnstile.siteKeyHint') }}
|
||||
<a href="https://dash.cloudflare.com/turnstile" target="_blank" class="text-primary-600 hover:text-primary-500">Cloudflare Dashboard</a>
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
{{ t('admin.settings.turnstile.secretKey') }}
|
||||
</label>
|
||||
<input
|
||||
v-model="form.turnstile_secret_key"
|
||||
type="password"
|
||||
class="input font-mono text-sm"
|
||||
placeholder="0x4AAAAAAA..."
|
||||
/>
|
||||
<p class="mt-1.5 text-xs text-gray-500 dark:text-gray-400">{{ t('admin.settings.turnstile.secretKeyHint') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Default Settings -->
|
||||
<div class="card">
|
||||
<div class="px-6 py-4 border-b border-gray-100 dark:border-dark-700">
|
||||
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">{{ t('admin.settings.defaults.title') }}</h2>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400 mt-1">{{ t('admin.settings.defaults.description') }}</p>
|
||||
</div>
|
||||
<div class="p-6">
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
{{ t('admin.settings.defaults.defaultBalance') }}
|
||||
</label>
|
||||
<input
|
||||
v-model.number="form.default_balance"
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
class="input"
|
||||
placeholder="0.00"
|
||||
/>
|
||||
<p class="mt-1.5 text-xs text-gray-500 dark:text-gray-400">{{ t('admin.settings.defaults.defaultBalanceHint') }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
{{ t('admin.settings.defaults.defaultConcurrency') }}
|
||||
</label>
|
||||
<input
|
||||
v-model.number="form.default_concurrency"
|
||||
type="number"
|
||||
min="1"
|
||||
class="input"
|
||||
placeholder="1"
|
||||
/>
|
||||
<p class="mt-1.5 text-xs text-gray-500 dark:text-gray-400">{{ t('admin.settings.defaults.defaultConcurrencyHint') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Site Settings -->
|
||||
<div class="card">
|
||||
<div class="px-6 py-4 border-b border-gray-100 dark:border-dark-700">
|
||||
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">{{ t('admin.settings.site.title') }}</h2>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400 mt-1">{{ t('admin.settings.site.description') }}</p>
|
||||
</div>
|
||||
<div class="p-6 space-y-6">
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
{{ t('admin.settings.site.siteName') }}
|
||||
</label>
|
||||
<input
|
||||
v-model="form.site_name"
|
||||
type="text"
|
||||
class="input"
|
||||
placeholder="Sub2API"
|
||||
/>
|
||||
<p class="mt-1.5 text-xs text-gray-500 dark:text-gray-400">{{ t('admin.settings.site.siteNameHint') }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
{{ t('admin.settings.site.siteSubtitle') }}
|
||||
</label>
|
||||
<input
|
||||
v-model="form.site_subtitle"
|
||||
type="text"
|
||||
class="input"
|
||||
placeholder="Subscription to API Conversion Platform"
|
||||
/>
|
||||
<p class="mt-1.5 text-xs text-gray-500 dark:text-gray-400">{{ t('admin.settings.site.siteSubtitleHint') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- API Base URL -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
{{ t('admin.settings.site.apiBaseUrl') }}
|
||||
</label>
|
||||
<input
|
||||
v-model="form.api_base_url"
|
||||
type="text"
|
||||
class="input font-mono text-sm"
|
||||
placeholder="https://api.example.com"
|
||||
/>
|
||||
<p class="mt-1.5 text-xs text-gray-500 dark:text-gray-400">{{ t('admin.settings.site.apiBaseUrlHint') }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Contact Info -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
{{ t('admin.settings.site.contactInfo') }}
|
||||
</label>
|
||||
<input
|
||||
v-model="form.contact_info"
|
||||
type="text"
|
||||
class="input"
|
||||
:placeholder="t('admin.settings.site.contactInfoPlaceholder')"
|
||||
/>
|
||||
<p class="mt-1.5 text-xs text-gray-500 dark:text-gray-400">{{ t('admin.settings.site.contactInfoHint') }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Site Logo Upload -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
{{ t('admin.settings.site.siteLogo') }}
|
||||
</label>
|
||||
<div class="flex items-start gap-6">
|
||||
<!-- Logo Preview -->
|
||||
<div class="flex-shrink-0">
|
||||
<div
|
||||
class="w-20 h-20 rounded-xl border-2 border-dashed border-gray-300 dark:border-dark-600 flex items-center justify-center overflow-hidden bg-gray-50 dark:bg-dark-800"
|
||||
:class="{ 'border-solid': form.site_logo }"
|
||||
>
|
||||
<img
|
||||
v-if="form.site_logo"
|
||||
:src="form.site_logo"
|
||||
alt="Site Logo"
|
||||
class="w-full h-full object-contain"
|
||||
/>
|
||||
<svg v-else class="w-8 h-8 text-gray-400 dark:text-dark-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Upload Controls -->
|
||||
<div class="flex-1 space-y-3">
|
||||
<div class="flex items-center gap-3">
|
||||
<label class="btn btn-secondary btn-sm cursor-pointer">
|
||||
<input
|
||||
type="file"
|
||||
accept="image/*"
|
||||
class="hidden"
|
||||
@change="handleLogoUpload"
|
||||
/>
|
||||
<svg class="w-4 h-4 mr-1.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12" />
|
||||
</svg>
|
||||
{{ t('admin.settings.site.uploadImage') }}
|
||||
</label>
|
||||
<button
|
||||
v-if="form.site_logo"
|
||||
type="button"
|
||||
@click="form.site_logo = ''"
|
||||
class="btn btn-secondary btn-sm text-red-600 hover:text-red-700 dark:text-red-400"
|
||||
>
|
||||
<svg class="w-4 h-4 mr-1.5" 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('admin.settings.site.remove') }}
|
||||
</button>
|
||||
</div>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ t('admin.settings.site.logoHint') }}
|
||||
</p>
|
||||
<p v-if="logoError" class="text-xs text-red-500">{{ logoError }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- SMTP Settings - Only show when email verification is enabled -->
|
||||
<div v-if="form.email_verify_enabled" class="card">
|
||||
<div class="px-6 py-4 border-b border-gray-100 dark:border-dark-700 flex items-center justify-between">
|
||||
<div>
|
||||
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">{{ t('admin.settings.smtp.title') }}</h2>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400 mt-1">{{ t('admin.settings.smtp.description') }}</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
@click="testSmtpConnection"
|
||||
:disabled="testingSmtp"
|
||||
class="btn btn-secondary btn-sm"
|
||||
>
|
||||
<svg v-if="testingSmtp" class="animate-spin 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>
|
||||
{{ testingSmtp ? t('admin.settings.smtp.testing') : t('admin.settings.smtp.testConnection') }}
|
||||
</button>
|
||||
</div>
|
||||
<div class="p-6 space-y-6">
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
{{ t('admin.settings.smtp.host') }}
|
||||
</label>
|
||||
<input
|
||||
v-model="form.smtp_host"
|
||||
type="text"
|
||||
class="input"
|
||||
placeholder="smtp.gmail.com"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
{{ t('admin.settings.smtp.port') }}
|
||||
</label>
|
||||
<input
|
||||
v-model.number="form.smtp_port"
|
||||
type="number"
|
||||
min="1"
|
||||
max="65535"
|
||||
class="input"
|
||||
placeholder="587"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
{{ t('admin.settings.smtp.username') }}
|
||||
</label>
|
||||
<input
|
||||
v-model="form.smtp_username"
|
||||
type="text"
|
||||
class="input"
|
||||
placeholder="your-email@gmail.com"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
{{ t('admin.settings.smtp.password') }}
|
||||
</label>
|
||||
<input
|
||||
v-model="form.smtp_password"
|
||||
type="password"
|
||||
class="input"
|
||||
placeholder="********"
|
||||
/>
|
||||
<p class="mt-1.5 text-xs text-gray-500 dark:text-gray-400">{{ t('admin.settings.smtp.passwordHint') }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
{{ t('admin.settings.smtp.fromEmail') }}
|
||||
</label>
|
||||
<input
|
||||
v-model="form.smtp_from_email"
|
||||
type="email"
|
||||
class="input"
|
||||
placeholder="noreply@example.com"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
{{ t('admin.settings.smtp.fromName') }}
|
||||
</label>
|
||||
<input
|
||||
v-model="form.smtp_from_name"
|
||||
type="text"
|
||||
class="input"
|
||||
placeholder="Sub2API"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Use TLS Toggle -->
|
||||
<div class="flex items-center justify-between pt-4 border-t border-gray-100 dark:border-dark-700">
|
||||
<div>
|
||||
<label class="font-medium text-gray-900 dark:text-white">{{ t('admin.settings.smtp.useTls') }}</label>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">{{ t('admin.settings.smtp.useTlsHint') }}</p>
|
||||
</div>
|
||||
<Toggle v-model="form.smtp_use_tls" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Send Test Email - Only show when email verification is enabled -->
|
||||
<div v-if="form.email_verify_enabled" class="card">
|
||||
<div class="px-6 py-4 border-b border-gray-100 dark:border-dark-700">
|
||||
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">{{ t('admin.settings.testEmail.title') }}</h2>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400 mt-1">{{ t('admin.settings.testEmail.description') }}</p>
|
||||
</div>
|
||||
<div class="p-6">
|
||||
<div class="flex items-end gap-4">
|
||||
<div class="flex-1">
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
{{ t('admin.settings.testEmail.recipientEmail') }}
|
||||
</label>
|
||||
<input
|
||||
v-model="testEmailAddress"
|
||||
type="email"
|
||||
class="input"
|
||||
placeholder="test@example.com"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
@click="sendTestEmail"
|
||||
:disabled="sendingTestEmail || !testEmailAddress"
|
||||
class="btn btn-secondary"
|
||||
>
|
||||
<svg v-if="sendingTestEmail" class="animate-spin 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>
|
||||
{{ sendingTestEmail ? t('admin.settings.testEmail.sending') : t('admin.settings.testEmail.sendTestEmail') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Save Button -->
|
||||
<div class="flex justify-end">
|
||||
<button
|
||||
type="submit"
|
||||
:disabled="saving"
|
||||
class="btn btn-primary"
|
||||
>
|
||||
<svg v-if="saving" class="animate-spin 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>
|
||||
{{ saving ? t('admin.settings.saving') : t('admin.settings.saveSettings') }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</AppLayout>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, onMounted } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { adminAPI } from '@/api';
|
||||
import type { SystemSettings } from '@/api/admin/settings';
|
||||
import AppLayout from '@/components/layout/AppLayout.vue';
|
||||
import Toggle from '@/components/common/Toggle.vue';
|
||||
import { useAppStore } from '@/stores';
|
||||
|
||||
const { t } = useI18n();
|
||||
const appStore = useAppStore();
|
||||
|
||||
const loading = ref(true);
|
||||
const saving = ref(false);
|
||||
const testingSmtp = ref(false);
|
||||
const sendingTestEmail = ref(false);
|
||||
const testEmailAddress = ref('');
|
||||
const logoError = ref('');
|
||||
|
||||
const form = reactive<SystemSettings>({
|
||||
registration_enabled: true,
|
||||
email_verify_enabled: false,
|
||||
default_balance: 0,
|
||||
default_concurrency: 1,
|
||||
site_name: 'Sub2API',
|
||||
site_logo: '',
|
||||
site_subtitle: 'Subscription to API Conversion Platform',
|
||||
api_base_url: '',
|
||||
contact_info: '',
|
||||
smtp_host: '',
|
||||
smtp_port: 587,
|
||||
smtp_username: '',
|
||||
smtp_password: '',
|
||||
smtp_from_email: '',
|
||||
smtp_from_name: '',
|
||||
smtp_use_tls: true,
|
||||
// Cloudflare Turnstile
|
||||
turnstile_enabled: false,
|
||||
turnstile_site_key: '',
|
||||
turnstile_secret_key: '',
|
||||
});
|
||||
|
||||
function handleLogoUpload(event: Event) {
|
||||
const input = event.target as HTMLInputElement;
|
||||
const file = input.files?.[0];
|
||||
logoError.value = '';
|
||||
|
||||
if (!file) return;
|
||||
|
||||
// Check file size (300KB = 307200 bytes)
|
||||
const maxSize = 300 * 1024;
|
||||
if (file.size > maxSize) {
|
||||
logoError.value = t('admin.settings.site.logoSizeError', { size: (file.size / 1024).toFixed(1) });
|
||||
input.value = '';
|
||||
return;
|
||||
}
|
||||
|
||||
// Check file type
|
||||
if (!file.type.startsWith('image/')) {
|
||||
logoError.value = t('admin.settings.site.logoTypeError');
|
||||
input.value = '';
|
||||
return;
|
||||
}
|
||||
|
||||
// Convert to base64
|
||||
const reader = new FileReader();
|
||||
reader.onload = (e) => {
|
||||
form.site_logo = e.target?.result as string;
|
||||
};
|
||||
reader.onerror = () => {
|
||||
logoError.value = t('admin.settings.site.logoReadError');
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
|
||||
// Reset input
|
||||
input.value = '';
|
||||
}
|
||||
|
||||
async function loadSettings() {
|
||||
loading.value = true;
|
||||
try {
|
||||
const settings = await adminAPI.settings.getSettings();
|
||||
Object.assign(form, settings);
|
||||
} catch (error: any) {
|
||||
appStore.showError(t('admin.settings.failedToLoad') + ': ' + (error.message || t('common.unknownError')));
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function saveSettings() {
|
||||
saving.value = true;
|
||||
try {
|
||||
await adminAPI.settings.updateSettings(form);
|
||||
appStore.showSuccess(t('admin.settings.settingsSaved'));
|
||||
} catch (error: any) {
|
||||
appStore.showError(t('admin.settings.failedToSave') + ': ' + (error.message || t('common.unknownError')));
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function testSmtpConnection() {
|
||||
testingSmtp.value = true;
|
||||
try {
|
||||
const result = await adminAPI.settings.testSmtpConnection({
|
||||
smtp_host: form.smtp_host,
|
||||
smtp_port: form.smtp_port,
|
||||
smtp_username: form.smtp_username,
|
||||
smtp_password: form.smtp_password,
|
||||
smtp_use_tls: form.smtp_use_tls,
|
||||
});
|
||||
// API returns { message: "..." } on success, errors are thrown as exceptions
|
||||
appStore.showSuccess(result.message || t('admin.settings.smtpConnectionSuccess'));
|
||||
} catch (error: any) {
|
||||
appStore.showError(t('admin.settings.failedToTestSmtp') + ': ' + (error.message || t('common.unknownError')));
|
||||
} finally {
|
||||
testingSmtp.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function sendTestEmail() {
|
||||
if (!testEmailAddress.value) {
|
||||
appStore.showError(t('admin.settings.testEmail.enterRecipientHint'));
|
||||
return;
|
||||
}
|
||||
|
||||
sendingTestEmail.value = true;
|
||||
try {
|
||||
const result = await adminAPI.settings.sendTestEmail({
|
||||
email: testEmailAddress.value,
|
||||
smtp_host: form.smtp_host,
|
||||
smtp_port: form.smtp_port,
|
||||
smtp_username: form.smtp_username,
|
||||
smtp_password: form.smtp_password,
|
||||
smtp_from_email: form.smtp_from_email,
|
||||
smtp_from_name: form.smtp_from_name,
|
||||
smtp_use_tls: form.smtp_use_tls,
|
||||
});
|
||||
// API returns { message: "..." } on success, errors are thrown as exceptions
|
||||
appStore.showSuccess(result.message || t('admin.settings.testEmailSent'));
|
||||
} catch (error: any) {
|
||||
appStore.showError(t('admin.settings.failedToSendTestEmail') + ': ' + (error.message || t('common.unknownError')));
|
||||
} finally {
|
||||
sendingTestEmail.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadSettings();
|
||||
});
|
||||
</script>
|
||||
548
frontend/src/views/admin/SubscriptionsView.vue
Normal file
548
frontend/src/views/admin/SubscriptionsView.vue
Normal file
@@ -0,0 +1,548 @@
|
||||
<template>
|
||||
<AppLayout>
|
||||
<div class="space-y-6">
|
||||
<!-- Page Header Actions -->
|
||||
<div class="flex justify-end">
|
||||
<button
|
||||
@click="showAssignModal = 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('admin.subscriptions.assignSubscription') }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Filters -->
|
||||
<div class="flex flex-wrap gap-3">
|
||||
<Select
|
||||
v-model="filters.status"
|
||||
:options="statusOptions"
|
||||
:placeholder="t('admin.subscriptions.allStatus')"
|
||||
class="w-40"
|
||||
@change="loadSubscriptions"
|
||||
/>
|
||||
<Select
|
||||
v-model="filters.group_id"
|
||||
:options="groupOptions"
|
||||
:placeholder="t('admin.subscriptions.allGroups')"
|
||||
class="w-48"
|
||||
@change="loadSubscriptions"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Subscriptions Table -->
|
||||
<div class="card overflow-hidden">
|
||||
<DataTable :columns="columns" :data="subscriptions" :loading="loading">
|
||||
<template #cell-user="{ row }">
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="flex h-8 w-8 items-center justify-center rounded-full bg-primary-100 dark:bg-primary-900/30">
|
||||
<span class="text-sm font-medium text-primary-700 dark:text-primary-300">
|
||||
{{ row.user?.email?.charAt(0).toUpperCase() || '?' }}
|
||||
</span>
|
||||
</div>
|
||||
<span class="font-medium text-gray-900 dark:text-white">{{ row.user?.email || `User #${row.user_id}` }}</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #cell-group="{ row }">
|
||||
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-purple-100 text-purple-800 dark:bg-purple-900/30 dark:text-purple-400">
|
||||
{{ row.group?.name || `Group #${row.group_id}` }}
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<template #cell-usage="{ row }">
|
||||
<div class="space-y-1 min-w-[200px]">
|
||||
<div v-if="row.group?.daily_limit_usd" class="flex items-center gap-2">
|
||||
<span class="text-xs text-gray-500 w-12">{{ t('admin.subscriptions.daily') }}</span>
|
||||
<div class="flex-1 bg-gray-200 dark:bg-dark-600 rounded-full h-2">
|
||||
<div
|
||||
class="h-2 rounded-full transition-all"
|
||||
:class="getProgressClass(row.daily_usage_usd, row.group?.daily_limit_usd)"
|
||||
:style="{ width: getProgressWidth(row.daily_usage_usd, row.group?.daily_limit_usd) }"
|
||||
></div>
|
||||
</div>
|
||||
<span class="text-xs text-gray-500 w-20 text-right">
|
||||
${{ row.daily_usage_usd?.toFixed(2) || '0.00' }} / ${{ row.group?.daily_limit_usd?.toFixed(2) }}
|
||||
</span>
|
||||
</div>
|
||||
<div v-if="row.group?.weekly_limit_usd" class="flex items-center gap-2">
|
||||
<span class="text-xs text-gray-500 w-12">{{ t('admin.subscriptions.weekly') }}</span>
|
||||
<div class="flex-1 bg-gray-200 dark:bg-dark-600 rounded-full h-2">
|
||||
<div
|
||||
class="h-2 rounded-full transition-all"
|
||||
:class="getProgressClass(row.weekly_usage_usd, row.group?.weekly_limit_usd)"
|
||||
:style="{ width: getProgressWidth(row.weekly_usage_usd, row.group?.weekly_limit_usd) }"
|
||||
></div>
|
||||
</div>
|
||||
<span class="text-xs text-gray-500 w-20 text-right">
|
||||
${{ row.weekly_usage_usd?.toFixed(2) || '0.00' }} / ${{ row.group?.weekly_limit_usd?.toFixed(2) }}
|
||||
</span>
|
||||
</div>
|
||||
<div v-if="row.group?.monthly_limit_usd" class="flex items-center gap-2">
|
||||
<span class="text-xs text-gray-500 w-12">{{ t('admin.subscriptions.monthly') }}</span>
|
||||
<div class="flex-1 bg-gray-200 dark:bg-dark-600 rounded-full h-2">
|
||||
<div
|
||||
class="h-2 rounded-full transition-all"
|
||||
:class="getProgressClass(row.monthly_usage_usd, row.group?.monthly_limit_usd)"
|
||||
:style="{ width: getProgressWidth(row.monthly_usage_usd, row.group?.monthly_limit_usd) }"
|
||||
></div>
|
||||
</div>
|
||||
<span class="text-xs text-gray-500 w-20 text-right">
|
||||
${{ row.monthly_usage_usd?.toFixed(2) || '0.00' }} / ${{ row.group?.monthly_limit_usd?.toFixed(2) }}
|
||||
</span>
|
||||
</div>
|
||||
<div v-if="!row.group?.daily_limit_usd && !row.group?.weekly_limit_usd && !row.group?.monthly_limit_usd" class="text-xs text-gray-500">
|
||||
{{ t('admin.subscriptions.noLimits') }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #cell-expires_at="{ value }">
|
||||
<div v-if="value">
|
||||
<span class="text-sm" :class="isExpiringSoon(value) ? 'text-orange-600 dark:text-orange-400' : 'text-gray-700 dark:text-gray-300'">
|
||||
{{ formatDate(value) }}
|
||||
</span>
|
||||
<div v-if="getDaysRemaining(value) !== null" class="text-xs text-gray-500">
|
||||
{{ getDaysRemaining(value) }} {{ t('admin.subscriptions.daysRemaining') }}
|
||||
</div>
|
||||
</div>
|
||||
<span v-else class="text-sm text-gray-500">{{ t('admin.subscriptions.noExpiration') }}</span>
|
||||
</template>
|
||||
|
||||
<template #cell-status="{ value }">
|
||||
<span
|
||||
:class="[
|
||||
'badge',
|
||||
value === 'active' ? 'badge-success' : value === 'expired' ? 'badge-warning' : 'badge-danger'
|
||||
]"
|
||||
>
|
||||
{{ t(`admin.subscriptions.status.${value}`) }}
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<template #cell-actions="{ row }">
|
||||
<div class="flex items-center gap-1">
|
||||
<button
|
||||
v-if="row.status === 'active'"
|
||||
@click="handleExtend(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('admin.subscriptions.extend')"
|
||||
>
|
||||
<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="M12 6v6h4.5m4.5 0a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
v-if="row.status === 'active'"
|
||||
@click="handleRevoke(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="t('admin.subscriptions.revoke')"
|
||||
>
|
||||
<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="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>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #empty>
|
||||
<EmptyState
|
||||
:title="t('admin.subscriptions.noSubscriptionsYet')"
|
||||
:description="t('admin.subscriptions.assignFirstSubscription')"
|
||||
:action-text="t('admin.subscriptions.assignSubscription')"
|
||||
@action="showAssignModal = 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>
|
||||
|
||||
<!-- Assign Subscription Modal -->
|
||||
<Modal
|
||||
:show="showAssignModal"
|
||||
:title="t('admin.subscriptions.assignSubscription')"
|
||||
size="lg"
|
||||
@close="closeAssignModal"
|
||||
>
|
||||
<form @submit.prevent="handleAssignSubscription" class="space-y-5">
|
||||
<div>
|
||||
<label class="input-label">{{ t('admin.subscriptions.form.user') }}</label>
|
||||
<Select
|
||||
v-model="assignForm.user_id"
|
||||
:options="userOptions"
|
||||
:placeholder="t('admin.subscriptions.selectUser')"
|
||||
searchable
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="input-label">{{ t('admin.subscriptions.form.group') }}</label>
|
||||
<Select
|
||||
v-model="assignForm.group_id"
|
||||
:options="subscriptionGroupOptions"
|
||||
:placeholder="t('admin.subscriptions.selectGroup')"
|
||||
/>
|
||||
<p class="input-hint">{{ t('admin.subscriptions.groupHint') }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<label class="input-label">{{ t('admin.subscriptions.form.validityDays') }}</label>
|
||||
<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">
|
||||
<button
|
||||
@click="closeAssignModal"
|
||||
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('admin.subscriptions.assigning') : t('admin.subscriptions.assign') }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</Modal>
|
||||
|
||||
<!-- Extend Subscription Modal -->
|
||||
<Modal
|
||||
:show="showExtendModal"
|
||||
:title="t('admin.subscriptions.extendSubscription')"
|
||||
size="md"
|
||||
@close="closeExtendModal"
|
||||
>
|
||||
<form v-if="extendingSubscription" @submit.prevent="handleExtendSubscription" class="space-y-5">
|
||||
<div class="p-4 bg-gray-50 dark:bg-dark-700 rounded-lg">
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400">
|
||||
{{ t('admin.subscriptions.extendingFor') }}
|
||||
<span class="font-medium text-gray-900 dark:text-white">{{ extendingSubscription.user?.email }}</span>
|
||||
</p>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400 mt-1">
|
||||
{{ t('admin.subscriptions.currentExpiration') }}:
|
||||
<span class="font-medium text-gray-900 dark:text-white">
|
||||
{{ extendingSubscription.expires_at ? formatDate(extendingSubscription.expires_at) : t('admin.subscriptions.noExpiration') }}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<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">
|
||||
<button
|
||||
@click="closeExtendModal"
|
||||
type="button"
|
||||
class="btn btn-secondary"
|
||||
>
|
||||
{{ t('common.cancel') }}
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
:disabled="submitting"
|
||||
class="btn btn-primary"
|
||||
>
|
||||
{{ submitting ? t('admin.subscriptions.extending') : t('admin.subscriptions.extend') }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</Modal>
|
||||
|
||||
<!-- Revoke Confirmation Dialog -->
|
||||
<ConfirmDialog
|
||||
:show="showRevokeDialog"
|
||||
:title="t('admin.subscriptions.revokeSubscription')"
|
||||
:message="t('admin.subscriptions.revokeConfirm', { user: revokingSubscription?.user?.email })"
|
||||
:confirm-text="t('admin.subscriptions.revoke')"
|
||||
:cancel-text="t('common.cancel')"
|
||||
:danger="true"
|
||||
@confirm="confirmRevoke"
|
||||
@cancel="showRevokeDialog = false"
|
||||
/>
|
||||
</AppLayout>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, computed, onMounted } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
import { adminAPI } from '@/api/admin'
|
||||
import type { UserSubscription, Group, User } from '@/types'
|
||||
import type { Column } from '@/components/common/DataTable.vue'
|
||||
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'
|
||||
|
||||
const { t } = useI18n()
|
||||
const appStore = useAppStore()
|
||||
|
||||
const columns = computed<Column[]>(() => [
|
||||
{ key: 'user', label: t('admin.subscriptions.columns.user'), sortable: true },
|
||||
{ key: 'group', label: t('admin.subscriptions.columns.group'), sortable: true },
|
||||
{ key: 'usage', label: t('admin.subscriptions.columns.usage'), sortable: false },
|
||||
{ key: 'expires_at', label: t('admin.subscriptions.columns.expires'), sortable: true },
|
||||
{ key: 'status', label: t('admin.subscriptions.columns.status'), sortable: true },
|
||||
{ key: 'actions', label: t('admin.subscriptions.columns.actions'), sortable: false }
|
||||
])
|
||||
|
||||
// Filter options
|
||||
const statusOptions = computed(() => [
|
||||
{ value: '', label: t('admin.subscriptions.allStatus') },
|
||||
{ value: 'active', label: t('admin.subscriptions.status.active') },
|
||||
{ value: 'expired', label: t('admin.subscriptions.status.expired') },
|
||||
{ value: 'revoked', label: t('admin.subscriptions.status.revoked') }
|
||||
])
|
||||
|
||||
const subscriptions = ref<UserSubscription[]>([])
|
||||
const groups = ref<Group[]>([])
|
||||
const users = ref<User[]>([])
|
||||
const loading = ref(false)
|
||||
const filters = reactive({
|
||||
status: '',
|
||||
group_id: ''
|
||||
})
|
||||
const pagination = reactive({
|
||||
page: 1,
|
||||
page_size: 20,
|
||||
total: 0,
|
||||
pages: 0
|
||||
})
|
||||
|
||||
const showAssignModal = ref(false)
|
||||
const showExtendModal = ref(false)
|
||||
const showRevokeDialog = ref(false)
|
||||
const submitting = ref(false)
|
||||
const extendingSubscription = ref<UserSubscription | null>(null)
|
||||
const revokingSubscription = ref<UserSubscription | null>(null)
|
||||
|
||||
const assignForm = reactive({
|
||||
user_id: null as number | null,
|
||||
group_id: null as number | null,
|
||||
validity_days: 30
|
||||
})
|
||||
|
||||
const extendForm = reactive({
|
||||
days: 30
|
||||
})
|
||||
|
||||
// Group options for filter (all groups)
|
||||
const groupOptions = computed(() => [
|
||||
{ value: '', label: t('admin.subscriptions.allGroups') },
|
||||
...groups.value.map(g => ({ value: g.id.toString(), label: g.name }))
|
||||
])
|
||||
|
||||
// Group options for assign (only subscription type groups)
|
||||
const subscriptionGroupOptions = computed(() =>
|
||||
groups.value
|
||||
.filter(g => g.subscription_type === 'subscription' && g.status === 'active')
|
||||
.map(g => ({ value: g.id, label: g.name }))
|
||||
)
|
||||
|
||||
// User options for assign
|
||||
const userOptions = computed(() =>
|
||||
users.value.map(u => ({ value: u.id, label: u.email }))
|
||||
)
|
||||
|
||||
const loadSubscriptions = async () => {
|
||||
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
|
||||
}
|
||||
)
|
||||
subscriptions.value = response.items
|
||||
pagination.total = response.total
|
||||
pagination.pages = response.pages
|
||||
} catch (error) {
|
||||
appStore.showError(t('admin.subscriptions.failedToLoad'))
|
||||
console.error('Error loading subscriptions:', error)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const loadGroups = async () => {
|
||||
try {
|
||||
groups.value = await adminAPI.groups.getAll()
|
||||
} catch (error) {
|
||||
console.error('Error loading groups:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const loadUsers = async () => {
|
||||
try {
|
||||
const response = await adminAPI.users.list(1, 1000)
|
||||
users.value = response.items
|
||||
} catch (error) {
|
||||
console.error('Error loading users:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const handlePageChange = (page: number) => {
|
||||
pagination.page = page
|
||||
loadSubscriptions()
|
||||
}
|
||||
|
||||
const closeAssignModal = () => {
|
||||
showAssignModal.value = false
|
||||
assignForm.user_id = null
|
||||
assignForm.group_id = null
|
||||
assignForm.validity_days = 30
|
||||
}
|
||||
|
||||
const handleAssignSubscription = async () => {
|
||||
if (!assignForm.user_id || !assignForm.group_id) return
|
||||
|
||||
submitting.value = true
|
||||
try {
|
||||
await adminAPI.subscriptions.assign({
|
||||
user_id: assignForm.user_id,
|
||||
group_id: assignForm.group_id,
|
||||
validity_days: assignForm.validity_days
|
||||
})
|
||||
appStore.showSuccess(t('admin.subscriptions.subscriptionAssigned'))
|
||||
closeAssignModal()
|
||||
loadSubscriptions()
|
||||
} catch (error: any) {
|
||||
appStore.showError(error.response?.data?.detail || t('admin.subscriptions.failedToAssign'))
|
||||
console.error('Error assigning subscription:', error)
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleExtend = (subscription: UserSubscription) => {
|
||||
extendingSubscription.value = subscription
|
||||
extendForm.days = 30
|
||||
showExtendModal.value = true
|
||||
}
|
||||
|
||||
const closeExtendModal = () => {
|
||||
showExtendModal.value = false
|
||||
extendingSubscription.value = null
|
||||
}
|
||||
|
||||
const handleExtendSubscription = async () => {
|
||||
if (!extendingSubscription.value) return
|
||||
|
||||
submitting.value = true
|
||||
try {
|
||||
await adminAPI.subscriptions.extend(extendingSubscription.value.id, {
|
||||
days: extendForm.days
|
||||
})
|
||||
appStore.showSuccess(t('admin.subscriptions.subscriptionExtended'))
|
||||
closeExtendModal()
|
||||
loadSubscriptions()
|
||||
} catch (error: any) {
|
||||
appStore.showError(error.response?.data?.detail || t('admin.subscriptions.failedToExtend'))
|
||||
console.error('Error extending subscription:', error)
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleRevoke = (subscription: UserSubscription) => {
|
||||
revokingSubscription.value = subscription
|
||||
showRevokeDialog.value = true
|
||||
}
|
||||
|
||||
const confirmRevoke = async () => {
|
||||
if (!revokingSubscription.value) return
|
||||
|
||||
try {
|
||||
await adminAPI.subscriptions.revoke(revokingSubscription.value.id)
|
||||
appStore.showSuccess(t('admin.subscriptions.subscriptionRevoked'))
|
||||
showRevokeDialog.value = false
|
||||
revokingSubscription.value = null
|
||||
loadSubscriptions()
|
||||
} catch (error: any) {
|
||||
appStore.showError(error.response?.data?.detail || t('admin.subscriptions.failedToRevoke'))
|
||||
console.error('Error revoking subscription:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// Helper functions
|
||||
const formatDate = (dateString: string) => {
|
||||
return new Date(dateString).toLocaleDateString(undefined, {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric'
|
||||
})
|
||||
}
|
||||
|
||||
const getDaysRemaining = (expiresAt: string): number | null => {
|
||||
const now = new Date()
|
||||
const expires = new Date(expiresAt)
|
||||
const diff = expires.getTime() - now.getTime()
|
||||
if (diff < 0) return null
|
||||
return Math.ceil(diff / (1000 * 60 * 60 * 24))
|
||||
}
|
||||
|
||||
const isExpiringSoon = (expiresAt: string): boolean => {
|
||||
const days = getDaysRemaining(expiresAt)
|
||||
return days !== null && days <= 7
|
||||
}
|
||||
|
||||
const getProgressWidth = (used: number, limit: number | null): string => {
|
||||
if (!limit || limit === 0) return '0%'
|
||||
const percentage = Math.min((used / limit) * 100, 100)
|
||||
return `${percentage}%`
|
||||
}
|
||||
|
||||
const getProgressClass = (used: number, limit: number | null): string => {
|
||||
if (!limit || limit === 0) return 'bg-gray-400'
|
||||
const percentage = (used / limit) * 100
|
||||
if (percentage >= 90) return 'bg-red-500'
|
||||
if (percentage >= 70) return 'bg-orange-500'
|
||||
return 'bg-green-500'
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadSubscriptions()
|
||||
loadGroups()
|
||||
loadUsers()
|
||||
})
|
||||
</script>
|
||||
593
frontend/src/views/admin/UsageView.vue
Normal file
593
frontend/src/views/admin/UsageView.vue
Normal file
@@ -0,0 +1,593 @@
|
||||
<template>
|
||||
<AppLayout>
|
||||
<div class="space-y-6">
|
||||
<!-- Summary Stats Cards -->
|
||||
<div class="grid grid-cols-2 gap-4 lg:grid-cols-4">
|
||||
<!-- Total Requests -->
|
||||
<div class="card p-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="p-2 rounded-lg bg-blue-100 dark:bg-blue-900/30">
|
||||
<svg class="w-5 h-5 text-blue-600 dark:text-blue-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs font-medium text-gray-500 dark:text-gray-400">{{ t('usage.totalRequests') }}</p>
|
||||
<p class="text-xl font-bold text-gray-900 dark:text-white">{{ usageStats?.total_requests?.toLocaleString() || '0' }}</p>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400">{{ t('usage.inSelectedRange') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Total Tokens -->
|
||||
<div class="card p-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="p-2 rounded-lg bg-amber-100 dark:bg-amber-900/30">
|
||||
<svg class="w-5 h-5 text-amber-600 dark:text-amber-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="m21 7.5-9-5.25L3 7.5m18 0-9 5.25m9-5.25v9l-9 5.25M3 7.5l9 5.25M3 7.5v9l9 5.25m0-9v9" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs font-medium text-gray-500 dark:text-gray-400">{{ t('usage.totalTokens') }}</p>
|
||||
<p class="text-xl font-bold text-gray-900 dark:text-white">{{ formatTokens(usageStats?.total_tokens || 0) }}</p>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400">{{ t('usage.in') }}: {{ formatTokens(usageStats?.total_input_tokens || 0) }} / {{ t('usage.out') }}: {{ formatTokens(usageStats?.total_output_tokens || 0) }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Total Cost -->
|
||||
<div class="card p-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="p-2 rounded-lg bg-green-100 dark:bg-green-900/30">
|
||||
<svg class="w-5 h-5 text-green-600 dark:text-green-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs font-medium text-gray-500 dark:text-gray-400">{{ t('usage.totalCost') }}</p>
|
||||
<div class="flex items-baseline gap-2">
|
||||
<p class="text-xl font-bold text-green-600 dark:text-green-400">${{ (usageStats?.total_actual_cost || 0).toFixed(4) }}</p>
|
||||
<span class="text-xs text-gray-400 dark:text-gray-500 line-through">${{ (usageStats?.total_cost || 0).toFixed(4) }}</span>
|
||||
</div>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400">{{ t('usage.actualCost') }} / {{ t('usage.standardCost') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Average Duration -->
|
||||
<div class="card p-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="p-2 rounded-lg bg-purple-100 dark:bg-purple-900/30">
|
||||
<svg class="w-5 h-5 text-purple-600 dark:text-purple-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6v6h4.5m4.5 0a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs font-medium text-gray-500 dark:text-gray-400">{{ t('usage.avgDuration') }}</p>
|
||||
<p class="text-xl font-bold text-gray-900 dark:text-white">{{ formatDuration(usageStats?.average_duration_ms || 0) }}</p>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400">{{ t('usage.perRequest') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Filters -->
|
||||
<div class="card">
|
||||
<div class="px-6 py-4">
|
||||
<div class="flex flex-wrap items-end gap-4">
|
||||
<!-- User Search -->
|
||||
<div class="min-w-[200px]">
|
||||
<label class="input-label">{{ t('admin.usage.userFilter') }}</label>
|
||||
<div class="relative">
|
||||
<input
|
||||
v-model="userSearchKeyword"
|
||||
type="text"
|
||||
class="input pr-8"
|
||||
:placeholder="t('admin.usage.searchUserPlaceholder')"
|
||||
@input="debounceSearchUsers"
|
||||
@focus="showUserDropdown = true"
|
||||
/>
|
||||
<button
|
||||
v-if="selectedUser"
|
||||
@click="clearUserFilter"
|
||||
class="absolute right-2 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
|
||||
>
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
<!-- User Dropdown -->
|
||||
<div
|
||||
v-if="showUserDropdown && (userSearchResults.length > 0 || userSearchKeyword)"
|
||||
class="absolute z-50 w-full mt-1 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg shadow-lg max-h-60 overflow-auto"
|
||||
>
|
||||
<div v-if="userSearchLoading" class="px-4 py-3 text-sm text-gray-500 dark:text-gray-400">
|
||||
{{ t('common.loading') }}
|
||||
</div>
|
||||
<div v-else-if="userSearchResults.length === 0 && userSearchKeyword" class="px-4 py-3 text-sm text-gray-500 dark:text-gray-400">
|
||||
{{ t('common.noOptionsFound') }}
|
||||
</div>
|
||||
<button
|
||||
v-for="user in userSearchResults"
|
||||
:key="user.id"
|
||||
@click="selectUser(user)"
|
||||
class="w-full px-4 py-2 text-left text-sm hover:bg-gray-100 dark:hover:bg-gray-700"
|
||||
>
|
||||
<span class="font-medium text-gray-900 dark:text-white">{{ user.email }}</span>
|
||||
<span class="text-gray-500 dark:text-gray-400 ml-2">#{{ user.id }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- API Key Filter -->
|
||||
<div class="min-w-[180px]">
|
||||
<label class="input-label">{{ t('usage.apiKeyFilter') }}</label>
|
||||
<Select
|
||||
v-model="filters.api_key_id"
|
||||
:options="apiKeyOptions"
|
||||
:placeholder="t('usage.allApiKeys')"
|
||||
:disabled="!selectedUser && apiKeys.length === 0"
|
||||
@change="applyFilters"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Date Range Filter -->
|
||||
<div>
|
||||
<label class="input-label">{{ t('usage.timeRange') }}</label>
|
||||
<DateRangePicker
|
||||
v-model:start-date="startDate"
|
||||
v-model:end-date="endDate"
|
||||
@change="onDateRangeChange"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="flex items-center gap-3 ml-auto">
|
||||
<button
|
||||
@click="resetFilters"
|
||||
class="btn btn-secondary"
|
||||
>
|
||||
{{ t('common.reset') }}
|
||||
</button>
|
||||
<button
|
||||
@click="exportToCSV"
|
||||
class="btn btn-primary"
|
||||
>
|
||||
{{ t('usage.exportCsv') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Usage Table -->
|
||||
<div class="card overflow-hidden">
|
||||
<DataTable
|
||||
:columns="columns"
|
||||
:data="usageLogs"
|
||||
:loading="loading"
|
||||
>
|
||||
<template #cell-user="{ row }">
|
||||
<div class="text-sm">
|
||||
<span class="font-medium text-gray-900 dark:text-white">{{ row.user?.email || '-' }}</span>
|
||||
<span class="text-gray-500 dark:text-gray-400 ml-1">#{{ row.user_id }}</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #cell-api_key="{ row }">
|
||||
<span class="text-sm text-gray-900 dark:text-white">{{ row.api_key?.name || '-' }}</span>
|
||||
</template>
|
||||
|
||||
<template #cell-model="{ value }">
|
||||
<span class="font-medium text-gray-900 dark:text-white">{{ value }}</span>
|
||||
</template>
|
||||
|
||||
<template #cell-stream="{ row }">
|
||||
<span
|
||||
class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium"
|
||||
:class="row.stream
|
||||
? 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200'
|
||||
: 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-200'"
|
||||
>
|
||||
{{ row.stream ? t('usage.stream') : t('usage.sync') }}
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<template #cell-tokens="{ row }">
|
||||
<div class="text-sm">
|
||||
<div class="flex items-center gap-1">
|
||||
<span class="text-gray-500 dark:text-gray-400">{{ t('usage.in') }}</span>
|
||||
<span class="font-medium text-gray-900 dark:text-white">{{ row.input_tokens.toLocaleString() }}</span>
|
||||
<span class="text-gray-400 dark:text-gray-500">/</span>
|
||||
<span class="text-gray-500 dark:text-gray-400">{{ t('usage.out') }}</span>
|
||||
<span class="font-medium text-gray-900 dark:text-white">{{ row.output_tokens.toLocaleString() }}</span>
|
||||
</div>
|
||||
<div v-if="row.cache_read_tokens > 0" class="flex items-center gap-1 text-blue-600 dark:text-blue-400">
|
||||
<span>{{ t('dashboard.cache') }}</span>
|
||||
<span class="font-medium">{{ row.cache_read_tokens.toLocaleString() }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #cell-cost="{ row }">
|
||||
<div class="text-sm flex items-center gap-1.5">
|
||||
<span class="font-medium text-green-600 dark:text-green-400">
|
||||
${{ row.actual_cost.toFixed(6) }}
|
||||
</span>
|
||||
<!-- Cost Detail Tooltip -->
|
||||
<div class="relative group">
|
||||
<div class="flex items-center justify-center w-4 h-4 rounded-full bg-gray-100 dark:bg-gray-700 cursor-help transition-colors group-hover:bg-blue-100 dark:group-hover:bg-blue-900/50">
|
||||
<svg class="w-3 h-3 text-gray-400 dark:text-gray-500 group-hover:text-blue-500 dark:group-hover:text-blue-400" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
</div>
|
||||
<!-- Tooltip Content (right side) -->
|
||||
<div class="absolute z-[100] invisible group-hover:visible opacity-0 group-hover:opacity-100 transition-all duration-200 left-full top-1/2 -translate-y-1/2 ml-2">
|
||||
<div class="bg-gray-900 dark:bg-gray-800 text-white text-xs rounded-lg py-2.5 px-3 shadow-xl whitespace-nowrap border border-gray-700 dark:border-gray-600">
|
||||
<div class="space-y-1.5">
|
||||
<div class="flex items-center justify-between gap-6">
|
||||
<span class="text-gray-400">{{ t('usage.rate') }}</span>
|
||||
<span class="font-semibold text-blue-400">{{ (row.rate_multiplier || 1).toFixed(2) }}x</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between gap-6">
|
||||
<span class="text-gray-400">{{ t('usage.original') }}</span>
|
||||
<span class="font-medium text-white">${{ row.total_cost.toFixed(6) }}</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between gap-6 pt-1.5 border-t border-gray-700">
|
||||
<span class="text-gray-400">{{ t('usage.billed') }}</span>
|
||||
<span class="font-semibold text-green-400">${{ row.actual_cost.toFixed(6) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Tooltip Arrow (left side) -->
|
||||
<div class="absolute right-full top-1/2 -translate-y-1/2 w-0 h-0 border-t-[6px] border-t-transparent border-b-[6px] border-b-transparent border-r-[6px] border-r-gray-900 dark:border-r-gray-800"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #cell-billing_type="{ row }">
|
||||
<span
|
||||
class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium"
|
||||
:class="row.billing_type === 1
|
||||
? 'bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-200'
|
||||
: 'bg-emerald-100 text-emerald-800 dark:bg-emerald-900 dark:text-emerald-200'"
|
||||
>
|
||||
{{ row.billing_type === 1 ? t('usage.subscription') : t('usage.balance') }}
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<template #cell-first_token="{ row }">
|
||||
<span v-if="row.first_token_ms != null" class="text-sm text-gray-600 dark:text-gray-400">
|
||||
{{ formatDuration(row.first_token_ms) }}
|
||||
</span>
|
||||
<span v-else class="text-sm text-gray-400 dark:text-gray-500">-</span>
|
||||
</template>
|
||||
|
||||
<template #cell-duration="{ row }">
|
||||
<span class="text-sm text-gray-600 dark:text-gray-400">{{ formatDuration(row.duration_ms) }}</span>
|
||||
</template>
|
||||
|
||||
<template #cell-created_at="{ value }">
|
||||
<span class="text-sm text-gray-600 dark:text-gray-400">{{ formatDateTime(value) }}</span>
|
||||
</template>
|
||||
|
||||
<template #empty>
|
||||
<EmptyState :message="t('usage.noRecords')" />
|
||||
</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>
|
||||
</AppLayout>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, onUnmounted, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
import { adminAPI } from '@/api/admin'
|
||||
import AppLayout from '@/components/layout/AppLayout.vue'
|
||||
import DataTable from '@/components/common/DataTable.vue'
|
||||
import Pagination from '@/components/common/Pagination.vue'
|
||||
import EmptyState from '@/components/common/EmptyState.vue'
|
||||
import Select from '@/components/common/Select.vue'
|
||||
import DateRangePicker from '@/components/common/DateRangePicker.vue'
|
||||
import type { UsageLog } from '@/types'
|
||||
import type { Column } from '@/components/common/DataTable.vue'
|
||||
import type { SimpleUser, SimpleApiKey, AdminUsageStatsResponse, AdminUsageQueryParams } from '@/api/admin/usage'
|
||||
|
||||
const { t } = useI18n()
|
||||
const appStore = useAppStore()
|
||||
|
||||
// Usage stats from API
|
||||
const usageStats = ref<AdminUsageStatsResponse | null>(null)
|
||||
|
||||
const columns = computed<Column[]>(() => [
|
||||
{ key: 'user', label: t('admin.usage.user'), sortable: false },
|
||||
{ key: 'api_key', label: t('usage.apiKeyFilter'), sortable: false },
|
||||
{ key: 'model', label: t('usage.model'), sortable: true },
|
||||
{ key: 'stream', label: t('usage.type'), sortable: false },
|
||||
{ key: 'tokens', label: t('usage.tokens'), sortable: false },
|
||||
{ key: 'cost', label: t('usage.cost'), sortable: false },
|
||||
{ key: 'billing_type', label: t('usage.billingType'), sortable: false },
|
||||
{ key: 'duration', label: t('usage.duration'), sortable: false },
|
||||
{ key: 'created_at', label: t('usage.time'), sortable: true }
|
||||
])
|
||||
|
||||
const usageLogs = ref<UsageLog[]>([])
|
||||
const apiKeys = ref<SimpleApiKey[]>([])
|
||||
const loading = ref(false)
|
||||
|
||||
// User search state
|
||||
const userSearchKeyword = ref('')
|
||||
const userSearchResults = ref<SimpleUser[]>([])
|
||||
const userSearchLoading = ref(false)
|
||||
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
|
||||
const apiKeyOptions = computed(() => {
|
||||
return [
|
||||
{ value: null, label: t('usage.allApiKeys') },
|
||||
...apiKeys.value.map(key => ({
|
||||
value: key.id,
|
||||
label: key.name
|
||||
}))
|
||||
]
|
||||
})
|
||||
|
||||
// Date range state
|
||||
const startDate = ref('')
|
||||
const endDate = ref('')
|
||||
|
||||
const filters = ref<AdminUsageQueryParams>({
|
||||
user_id: undefined,
|
||||
api_key_id: undefined,
|
||||
start_date: undefined,
|
||||
end_date: undefined
|
||||
})
|
||||
|
||||
// Initialize default date range (last 7 days)
|
||||
const initializeDateRange = () => {
|
||||
const now = new Date()
|
||||
const today = now.toISOString().split('T')[0]
|
||||
const weekAgo = new Date(now)
|
||||
weekAgo.setDate(weekAgo.getDate() - 6)
|
||||
|
||||
startDate.value = weekAgo.toISOString().split('T')[0]
|
||||
endDate.value = today
|
||||
filters.value.start_date = startDate.value
|
||||
filters.value.end_date = endDate.value
|
||||
}
|
||||
|
||||
// User search with debounce
|
||||
const debounceSearchUsers = () => {
|
||||
if (searchTimeout) {
|
||||
clearTimeout(searchTimeout)
|
||||
}
|
||||
searchTimeout = setTimeout(searchUsers, 300)
|
||||
}
|
||||
|
||||
const searchUsers = async () => {
|
||||
const keyword = userSearchKeyword.value.trim()
|
||||
if (!keyword) {
|
||||
userSearchResults.value = []
|
||||
return
|
||||
}
|
||||
|
||||
userSearchLoading.value = true
|
||||
try {
|
||||
userSearchResults.value = await adminAPI.usage.searchUsers(keyword)
|
||||
} catch (error) {
|
||||
console.error('Failed to search users:', error)
|
||||
userSearchResults.value = []
|
||||
} finally {
|
||||
userSearchLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const selectUser = async (user: SimpleUser) => {
|
||||
selectedUser.value = user
|
||||
userSearchKeyword.value = user.email
|
||||
showUserDropdown.value = false
|
||||
filters.value.user_id = user.id
|
||||
filters.value.api_key_id = undefined
|
||||
|
||||
// Load API keys for selected user
|
||||
await loadApiKeysForUser(user.id)
|
||||
applyFilters()
|
||||
}
|
||||
|
||||
const clearUserFilter = () => {
|
||||
selectedUser.value = null
|
||||
userSearchKeyword.value = ''
|
||||
userSearchResults.value = []
|
||||
filters.value.user_id = undefined
|
||||
filters.value.api_key_id = undefined
|
||||
apiKeys.value = []
|
||||
applyFilters()
|
||||
}
|
||||
|
||||
const loadApiKeysForUser = async (userId: number) => {
|
||||
try {
|
||||
apiKeys.value = await adminAPI.usage.searchApiKeys(userId)
|
||||
} catch (error) {
|
||||
console.error('Failed to load API keys:', error)
|
||||
apiKeys.value = []
|
||||
}
|
||||
}
|
||||
|
||||
// Handle date range change from DateRangePicker
|
||||
const onDateRangeChange = (range: { startDate: string; endDate: string; preset: string | null }) => {
|
||||
filters.value.start_date = range.startDate
|
||||
filters.value.end_date = range.endDate
|
||||
applyFilters()
|
||||
}
|
||||
|
||||
const pagination = ref({
|
||||
page: 1,
|
||||
page_size: 20,
|
||||
total: 0,
|
||||
pages: 0
|
||||
})
|
||||
|
||||
const formatDuration = (ms: number): string => {
|
||||
if (ms < 1000) return `${ms.toFixed(0)}ms`
|
||||
return `${(ms / 1000).toFixed(2)}s`
|
||||
}
|
||||
|
||||
const formatTokens = (value: number): string => {
|
||||
if (value >= 1_000_000_000) {
|
||||
return `${(value / 1_000_000_000).toFixed(2)}B`
|
||||
} else if (value >= 1_000_000) {
|
||||
return `${(value / 1_000_000).toFixed(2)}M`
|
||||
} else if (value >= 1_000) {
|
||||
return `${(value / 1_000).toFixed(2)}K`
|
||||
}
|
||||
return value.toLocaleString()
|
||||
}
|
||||
|
||||
const formatDateTime = (dateString: string): string => {
|
||||
const date = new Date(dateString)
|
||||
return date.toLocaleString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
})
|
||||
}
|
||||
|
||||
const loadUsageLogs = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const params: AdminUsageQueryParams = {
|
||||
page: pagination.value.page,
|
||||
page_size: pagination.value.page_size,
|
||||
...filters.value
|
||||
}
|
||||
|
||||
const response = await adminAPI.usage.list(params)
|
||||
usageLogs.value = response.items
|
||||
pagination.value.total = response.total
|
||||
pagination.value.pages = response.pages
|
||||
} catch (error) {
|
||||
appStore.showError(t('usage.failedToLoad'))
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const loadUsageStats = async () => {
|
||||
try {
|
||||
const stats = await adminAPI.usage.getStats({
|
||||
user_id: filters.value.user_id,
|
||||
api_key_id: filters.value.api_key_id ? Number(filters.value.api_key_id) : undefined,
|
||||
start_date: filters.value.start_date || startDate.value,
|
||||
end_date: filters.value.end_date || endDate.value
|
||||
})
|
||||
usageStats.value = stats
|
||||
} catch (error) {
|
||||
console.error('Failed to load usage stats:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const applyFilters = () => {
|
||||
pagination.value.page = 1
|
||||
loadUsageLogs()
|
||||
loadUsageStats()
|
||||
}
|
||||
|
||||
const resetFilters = () => {
|
||||
selectedUser.value = null
|
||||
userSearchKeyword.value = ''
|
||||
userSearchResults.value = []
|
||||
apiKeys.value = []
|
||||
filters.value = {
|
||||
user_id: undefined,
|
||||
api_key_id: undefined,
|
||||
start_date: undefined,
|
||||
end_date: undefined
|
||||
}
|
||||
// Reset date range to default (last 7 days)
|
||||
initializeDateRange()
|
||||
pagination.value.page = 1
|
||||
loadUsageLogs()
|
||||
loadUsageStats()
|
||||
}
|
||||
|
||||
const handlePageChange = (page: number) => {
|
||||
pagination.value.page = page
|
||||
loadUsageLogs()
|
||||
}
|
||||
|
||||
const exportToCSV = () => {
|
||||
if (usageLogs.value.length === 0) {
|
||||
appStore.showWarning(t('usage.noDataToExport'))
|
||||
return
|
||||
}
|
||||
|
||||
const headers = ['User', 'API Key', 'Model', 'Type', 'Input Tokens', 'Output Tokens', 'Cache Tokens', 'Total Cost', 'Billing Type', 'Duration (ms)', 'Time']
|
||||
const rows = usageLogs.value.map(log => [
|
||||
log.user?.email || '',
|
||||
log.api_key?.name || '',
|
||||
log.model,
|
||||
log.stream ? 'Stream' : 'Sync',
|
||||
log.input_tokens,
|
||||
log.output_tokens,
|
||||
log.cache_read_tokens,
|
||||
log.total_cost.toFixed(6),
|
||||
log.billing_type === 1 ? 'Subscription' : 'Balance',
|
||||
log.duration_ms,
|
||||
log.created_at
|
||||
])
|
||||
|
||||
const csvContent = [
|
||||
headers.join(','),
|
||||
...rows.map(row => row.join(','))
|
||||
].join('\n')
|
||||
|
||||
const blob = new Blob([csvContent], { type: 'text/csv' })
|
||||
const url = window.URL.createObjectURL(blob)
|
||||
const link = document.createElement('a')
|
||||
link.href = url
|
||||
link.download = `admin_usage_${new Date().toISOString().split('T')[0]}.csv`
|
||||
link.click()
|
||||
window.URL.revokeObjectURL(url)
|
||||
|
||||
appStore.showSuccess(t('usage.exportSuccess'))
|
||||
}
|
||||
|
||||
// Click outside to close dropdown
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
const target = event.target as HTMLElement
|
||||
if (!target.closest('.relative')) {
|
||||
showUserDropdown.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
initializeDateRange()
|
||||
loadUsageLogs()
|
||||
loadUsageStats()
|
||||
document.addEventListener('click', handleClickOutside)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
document.removeEventListener('click', handleClickOutside)
|
||||
if (searchTimeout) {
|
||||
clearTimeout(searchTimeout)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
1002
frontend/src/views/admin/UsersView.vue
Normal file
1002
frontend/src/views/admin/UsersView.vue
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user