431 lines
17 KiB
Vue
431 lines
17 KiB
Vue
|
|
<script setup lang="ts">
|
||
|
|
import { ref, onMounted, computed } from 'vue'
|
||
|
|
import { useI18n } from 'vue-i18n'
|
||
|
|
import soraAdminAPI, { type SoraSystemStats, type SoraUserStats, type SoraGenerationAdmin } from '@/api/admin/sora'
|
||
|
|
import AppLayout from '@/components/layout/AppLayout.vue'
|
||
|
|
import LoadingSpinner from '@/components/ui/LoadingSpinner.vue'
|
||
|
|
import Icon from '@/components/ui/Icon.vue'
|
||
|
|
import BaseButton from '@/components/ui/BaseButton.vue'
|
||
|
|
import BasePagination from '@/components/ui/BasePagination.vue'
|
||
|
|
|
||
|
|
const { t } = useI18n()
|
||
|
|
|
||
|
|
// State
|
||
|
|
const loading = ref(true)
|
||
|
|
const systemStats = ref<SoraSystemStats | null>(null)
|
||
|
|
const userStats = ref<SoraUserStats[]>([])
|
||
|
|
const generations = ref<SoraGenerationAdmin[]>([])
|
||
|
|
const activeTab = ref<'overview' | 'users' | 'generations'>('overview')
|
||
|
|
|
||
|
|
// Pagination
|
||
|
|
const userPage = ref(1)
|
||
|
|
const userPageSize = ref(20)
|
||
|
|
const userTotal = ref(0)
|
||
|
|
|
||
|
|
const genPage = ref(1)
|
||
|
|
const genPageSize = ref(20)
|
||
|
|
const genTotal = ref(0)
|
||
|
|
|
||
|
|
// Filters
|
||
|
|
const userSearch = ref('')
|
||
|
|
const genStatusFilter = ref('')
|
||
|
|
const genModelFilter = ref('')
|
||
|
|
|
||
|
|
// Computed
|
||
|
|
const userTotalPages = computed(() => Math.ceil(userTotal.value / userPageSize.value))
|
||
|
|
const genTotalPages = computed(() => Math.ceil(genTotal.value / genPageSize.value))
|
||
|
|
|
||
|
|
// Format helpers
|
||
|
|
function formatBytes(bytes: number): string {
|
||
|
|
if (bytes === 0) return '0 B'
|
||
|
|
const k = 1024
|
||
|
|
const sizes = ['B', 'KB', 'MB', 'GB', 'TB']
|
||
|
|
const i = Math.floor(Math.log(bytes) / Math.log(k))
|
||
|
|
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]
|
||
|
|
}
|
||
|
|
|
||
|
|
function formatDate(dateStr: string): string {
|
||
|
|
if (!dateStr) return '-'
|
||
|
|
return new Date(dateStr).toLocaleString()
|
||
|
|
}
|
||
|
|
|
||
|
|
function getStatusColor(status: string): string {
|
||
|
|
switch (status) {
|
||
|
|
case 'completed': return 'text-green-600 dark:text-green-400'
|
||
|
|
case 'failed': return 'text-red-600 dark:text-red-400'
|
||
|
|
case 'cancelled': return 'text-gray-500 dark:text-gray-400'
|
||
|
|
case 'pending': return 'text-yellow-600 dark:text-yellow-400'
|
||
|
|
case 'generating': return 'text-blue-600 dark:text-blue-400'
|
||
|
|
default: return 'text-gray-600 dark:text-gray-400'
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// API calls
|
||
|
|
async function fetchSystemStats() {
|
||
|
|
try {
|
||
|
|
systemStats.value = await soraAdminAPI.getSystemStats()
|
||
|
|
} catch (err) {
|
||
|
|
console.error('Failed to fetch system stats:', err)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
async function fetchUserStats() {
|
||
|
|
try {
|
||
|
|
const res = await soraAdminAPI.listUserStats({
|
||
|
|
page: userPage.value,
|
||
|
|
page_size: userPageSize.value,
|
||
|
|
search: userSearch.value || undefined
|
||
|
|
})
|
||
|
|
userStats.value = res.items
|
||
|
|
userTotal.value = res.total
|
||
|
|
} catch (err) {
|
||
|
|
console.error('Failed to fetch user stats:', err)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
async function fetchGenerations() {
|
||
|
|
try {
|
||
|
|
const res = await soraAdminAPI.listGenerations({
|
||
|
|
page: genPage.value,
|
||
|
|
page_size: genPageSize.value,
|
||
|
|
status: genStatusFilter.value || undefined,
|
||
|
|
model: genModelFilter.value || undefined
|
||
|
|
})
|
||
|
|
generations.value = res.items
|
||
|
|
genTotal.value = res.total
|
||
|
|
} catch (err) {
|
||
|
|
console.error('Failed to fetch generations:', err)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
async function clearUserStorage(userId: number) {
|
||
|
|
if (!confirm(t('admin.sora.confirmClearStorage'))) return
|
||
|
|
try {
|
||
|
|
await soraAdminAPI.clearUserStorage(userId)
|
||
|
|
await fetchUserStats()
|
||
|
|
} catch (err) {
|
||
|
|
console.error('Failed to clear user storage:', err)
|
||
|
|
alert(t('common.error'))
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
async function loadAll() {
|
||
|
|
loading.value = true
|
||
|
|
await Promise.all([
|
||
|
|
fetchSystemStats(),
|
||
|
|
fetchUserStats(),
|
||
|
|
fetchGenerations()
|
||
|
|
])
|
||
|
|
loading.value = false
|
||
|
|
}
|
||
|
|
|
||
|
|
// Event handlers
|
||
|
|
function onUserPageChange(page: number) {
|
||
|
|
userPage.value = page
|
||
|
|
fetchUserStats()
|
||
|
|
}
|
||
|
|
|
||
|
|
function onGenPageChange(page: number) {
|
||
|
|
genPage.value = page
|
||
|
|
fetchGenerations()
|
||
|
|
}
|
||
|
|
|
||
|
|
function onTabChange(tab: 'overview' | 'users' | 'generations') {
|
||
|
|
activeTab.value = tab
|
||
|
|
}
|
||
|
|
|
||
|
|
onMounted(loadAll)
|
||
|
|
</script>
|
||
|
|
|
||
|
|
<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>
|
||
|
|
<!-- Header -->
|
||
|
|
<div class="flex items-center justify-between">
|
||
|
|
<div>
|
||
|
|
<h1 class="text-2xl font-bold text-gray-900 dark:text-white">
|
||
|
|
{{ t('admin.sora.title') }}
|
||
|
|
</h1>
|
||
|
|
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
||
|
|
{{ t('admin.sora.description') }}
|
||
|
|
</p>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<!-- Tabs -->
|
||
|
|
<div class="border-b border-gray-200 dark:border-gray-700">
|
||
|
|
<nav class="flex space-x-8">
|
||
|
|
<button
|
||
|
|
@click="onTabChange('overview')"
|
||
|
|
:class="[
|
||
|
|
activeTab === 'overview'
|
||
|
|
? 'border-blue-500 text-blue-600 dark:text-blue-400'
|
||
|
|
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300 dark:text-gray-400 dark:hover:text-gray-300',
|
||
|
|
'whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm transition-colors'
|
||
|
|
]"
|
||
|
|
>
|
||
|
|
{{ t('admin.sora.overview') }}
|
||
|
|
</button>
|
||
|
|
<button
|
||
|
|
@click="onTabChange('users')"
|
||
|
|
:class="[
|
||
|
|
activeTab === 'users'
|
||
|
|
? 'border-blue-500 text-blue-600 dark:text-blue-400'
|
||
|
|
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300 dark:text-gray-400 dark:hover:text-gray-300',
|
||
|
|
'whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm transition-colors'
|
||
|
|
]"
|
||
|
|
>
|
||
|
|
{{ t('admin.sora.userStats') }}
|
||
|
|
</button>
|
||
|
|
<button
|
||
|
|
@click="onTabChange('generations')"
|
||
|
|
:class="[
|
||
|
|
activeTab === 'generations'
|
||
|
|
? 'border-blue-500 text-blue-600 dark:text-blue-400'
|
||
|
|
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300 dark:text-gray-400 dark:hover:text-gray-300',
|
||
|
|
'whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm transition-colors'
|
||
|
|
]"
|
||
|
|
>
|
||
|
|
{{ t('admin.sora.generations') }}
|
||
|
|
</button>
|
||
|
|
</nav>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<!-- Overview Tab -->
|
||
|
|
<div v-if="activeTab === 'overview' && systemStats" class="space-y-6">
|
||
|
|
<!-- Stats Cards -->
|
||
|
|
<div class="grid grid-cols-2 gap-4 lg:grid-cols-4">
|
||
|
|
<div class="card p-4">
|
||
|
|
<div class="flex items-center gap-3">
|
||
|
|
<div class="rounded-lg bg-purple-100 p-2 dark:bg-purple-900/30">
|
||
|
|
<Icon name="users" size="md" class="text-purple-600 dark:text-purple-400" />
|
||
|
|
</div>
|
||
|
|
<div>
|
||
|
|
<p class="text-xs font-medium text-gray-500 dark:text-gray-400">
|
||
|
|
{{ t('admin.sora.totalUsers') }}
|
||
|
|
</p>
|
||
|
|
<p class="text-xl font-bold text-gray-900 dark:text-white">
|
||
|
|
{{ systemStats.total_users }}
|
||
|
|
</p>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<div class="card p-4">
|
||
|
|
<div class="flex items-center gap-3">
|
||
|
|
<div class="rounded-lg bg-blue-100 p-2 dark:bg-blue-900/30">
|
||
|
|
<Icon name="video" size="md" class="text-blue-600 dark:text-blue-400" />
|
||
|
|
</div>
|
||
|
|
<div>
|
||
|
|
<p class="text-xs font-medium text-gray-500 dark:text-gray-400">
|
||
|
|
{{ t('admin.sora.totalGenerations') }}
|
||
|
|
</p>
|
||
|
|
<p class="text-xl font-bold text-gray-900 dark:text-white">
|
||
|
|
{{ systemStats.total_generations }}
|
||
|
|
</p>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<div class="card p-4">
|
||
|
|
<div class="flex items-center gap-3">
|
||
|
|
<div class="rounded-lg bg-green-100 p-2 dark:bg-green-900/30">
|
||
|
|
<Icon name="activity" size="md" class="text-green-600 dark:text-green-400" />
|
||
|
|
</div>
|
||
|
|
<div>
|
||
|
|
<p class="text-xs font-medium text-gray-500 dark:text-gray-400">
|
||
|
|
{{ t('admin.sora.activeGenerations') }}
|
||
|
|
</p>
|
||
|
|
<p class="text-xl font-bold text-gray-900 dark:text-white">
|
||
|
|
{{ systemStats.active_generations }}
|
||
|
|
</p>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<div class="card p-4">
|
||
|
|
<div class="flex items-center gap-3">
|
||
|
|
<div class="rounded-lg bg-orange-100 p-2 dark:bg-orange-900/30">
|
||
|
|
<Icon name="database" size="md" class="text-orange-600 dark:text-orange-400" />
|
||
|
|
</div>
|
||
|
|
<div>
|
||
|
|
<p class="text-xs font-medium text-gray-500 dark:text-gray-400">
|
||
|
|
{{ t('admin.sora.totalStorage') }}
|
||
|
|
</p>
|
||
|
|
<p class="text-xl font-bold text-gray-900 dark:text-white">
|
||
|
|
{{ formatBytes(systemStats.total_storage_bytes) }}
|
||
|
|
</p>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<!-- By Status -->
|
||
|
|
<div class="card p-4">
|
||
|
|
<h3 class="mb-4 text-lg font-semibold text-gray-900 dark:text-white">
|
||
|
|
{{ t('admin.sora.byStatus') }}
|
||
|
|
</h3>
|
||
|
|
<div class="grid grid-cols-2 gap-4 md:grid-cols-5">
|
||
|
|
<div v-for="(count, status) in systemStats.by_status" :key="status" class="text-center">
|
||
|
|
<p class="text-2xl font-bold" :class="getStatusColor(status)">{{ count }}</p>
|
||
|
|
<p class="text-sm text-gray-500 dark:text-gray-400">{{ status }}</p>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<!-- By Model -->
|
||
|
|
<div class="card p-4">
|
||
|
|
<h3 class="mb-4 text-lg font-semibold text-gray-900 dark:text-white">
|
||
|
|
{{ t('admin.sora.byModel') }}
|
||
|
|
</h3>
|
||
|
|
<div class="grid grid-cols-2 gap-4 md:grid-cols-4">
|
||
|
|
<div v-for="(count, model) in systemStats.by_model" :key="model" class="text-center">
|
||
|
|
<p class="text-2xl font-bold text-gray-900 dark:text-white">{{ count }}</p>
|
||
|
|
<p class="text-sm text-gray-500 dark:text-gray-400">{{ model }}</p>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<!-- Users Tab -->
|
||
|
|
<div v-if="activeTab === 'users'" class="space-y-4">
|
||
|
|
<!-- Search -->
|
||
|
|
<div class="flex items-center gap-4">
|
||
|
|
<input
|
||
|
|
v-model="userSearch"
|
||
|
|
type="text"
|
||
|
|
:placeholder="t('common.search')"
|
||
|
|
class="input flex-1"
|
||
|
|
@keyup.enter="fetchUserStats"
|
||
|
|
/>
|
||
|
|
<BaseButton @click="fetchUserStats">{{ t('common.search') }}</BaseButton>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<!-- Table -->
|
||
|
|
<div class="card overflow-x-auto">
|
||
|
|
<table class="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
|
||
|
|
<thead>
|
||
|
|
<tr>
|
||
|
|
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">
|
||
|
|
{{ t('admin.users.username') }}
|
||
|
|
</th>
|
||
|
|
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">
|
||
|
|
{{ t('admin.users.email') }}
|
||
|
|
</th>
|
||
|
|
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">
|
||
|
|
{{ t('admin.sora.quota') }}
|
||
|
|
</th>
|
||
|
|
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">
|
||
|
|
{{ t('admin.sora.used') }}
|
||
|
|
</th>
|
||
|
|
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">
|
||
|
|
{{ t('admin.sora.generations') }}
|
||
|
|
</th>
|
||
|
|
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">
|
||
|
|
{{ t('common.actions') }}
|
||
|
|
</th>
|
||
|
|
</tr>
|
||
|
|
</thead>
|
||
|
|
<tbody class="divide-y divide-gray-200 dark:divide-gray-700">
|
||
|
|
<tr v-for="user in userStats" :key="user.user_id">
|
||
|
|
<td class="px-4 py-3 text-sm text-gray-900 dark:text-white">{{ user.username }}</td>
|
||
|
|
<td class="px-4 py-3 text-sm text-gray-500 dark:text-gray-400">{{ user.email }}</td>
|
||
|
|
<td class="px-4 py-3 text-sm text-gray-900 dark:text-white">{{ formatBytes(user.quota_bytes) }}</td>
|
||
|
|
<td class="px-4 py-3 text-sm text-gray-900 dark:text-white">{{ formatBytes(user.used_bytes) }}</td>
|
||
|
|
<td class="px-4 py-3 text-sm text-gray-900 dark:text-white">{{ user.generations_count }}</td>
|
||
|
|
<td class="px-4 py-3 text-sm">
|
||
|
|
<BaseButton
|
||
|
|
variant="danger"
|
||
|
|
size="sm"
|
||
|
|
@click="clearUserStorage(user.user_id)"
|
||
|
|
>
|
||
|
|
{{ t('admin.sora.clearStorage') }}
|
||
|
|
</BaseButton>
|
||
|
|
</td>
|
||
|
|
</tr>
|
||
|
|
</tbody>
|
||
|
|
</table>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<!-- Pagination -->
|
||
|
|
<BasePagination
|
||
|
|
v-if="userTotalPages > 1"
|
||
|
|
:current="userPage"
|
||
|
|
:total="userTotalPages"
|
||
|
|
@change="onUserPageChange"
|
||
|
|
/>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<!-- Generations Tab -->
|
||
|
|
<div v-if="activeTab === 'generations'" class="space-y-4">
|
||
|
|
<!-- Filters -->
|
||
|
|
<div class="flex items-center gap-4">
|
||
|
|
<select v-model="genStatusFilter" class="input" @change="fetchGenerations">
|
||
|
|
<option value="">{{ t('common.allStatus') }}</option>
|
||
|
|
<option value="pending">Pending</option>
|
||
|
|
<option value="generating">Generating</option>
|
||
|
|
<option value="completed">Completed</option>
|
||
|
|
<option value="failed">Failed</option>
|
||
|
|
<option value="cancelled">Cancelled</option>
|
||
|
|
</select>
|
||
|
|
<select v-model="genModelFilter" class="input" @change="fetchGenerations">
|
||
|
|
<option value="">{{ t('common.allModels') }}</option>
|
||
|
|
<option value="sora2">Sora 2</option>
|
||
|
|
</select>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<!-- Table -->
|
||
|
|
<div class="card overflow-x-auto">
|
||
|
|
<table class="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
|
||
|
|
<thead>
|
||
|
|
<tr>
|
||
|
|
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">ID</th>
|
||
|
|
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">
|
||
|
|
{{ t('admin.users.username') }}
|
||
|
|
</th>
|
||
|
|
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">
|
||
|
|
{{ t('admin.sora.model') }}
|
||
|
|
</th>
|
||
|
|
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">
|
||
|
|
{{ t('admin.sora.status') }}
|
||
|
|
</th>
|
||
|
|
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">
|
||
|
|
{{ t('admin.sora.size') }}
|
||
|
|
</th>
|
||
|
|
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">
|
||
|
|
{{ t('admin.sora.createdAt') }}
|
||
|
|
</th>
|
||
|
|
</tr>
|
||
|
|
</thead>
|
||
|
|
<tbody class="divide-y divide-gray-200 dark:divide-gray-700">
|
||
|
|
<tr v-for="gen in generations" :key="gen.id">
|
||
|
|
<td class="px-4 py-3 text-sm text-gray-900 dark:text-white">{{ gen.id }}</td>
|
||
|
|
<td class="px-4 py-3 text-sm text-gray-500 dark:text-gray-400">{{ gen.username }}</td>
|
||
|
|
<td class="px-4 py-3 text-sm text-gray-900 dark:text-white">{{ gen.model }}</td>
|
||
|
|
<td class="px-4 py-3 text-sm" :class="getStatusColor(gen.status)">{{ gen.status }}</td>
|
||
|
|
<td class="px-4 py-3 text-sm text-gray-900 dark:text-white">{{ formatBytes(gen.file_size_bytes) }}</td>
|
||
|
|
<td class="px-4 py-3 text-sm text-gray-500 dark:text-gray-400">{{ formatDate(gen.created_at) }}</td>
|
||
|
|
</tr>
|
||
|
|
</tbody>
|
||
|
|
</table>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<!-- Pagination -->
|
||
|
|
<BasePagination
|
||
|
|
v-if="genTotalPages > 1"
|
||
|
|
:current="genPage"
|
||
|
|
:total="genTotalPages"
|
||
|
|
@change="onGenPageChange"
|
||
|
|
/>
|
||
|
|
</div>
|
||
|
|
</template>
|
||
|
|
</div>
|
||
|
|
</AppLayout>
|
||
|
|
</template>
|