Files
tokens-reef/frontend/src/views/admin/SoraAdminView.vue
pham ed642e8769
Some checks failed
CI / test (push) Has been cancelled
CI / golangci-lint (push) Has been cancelled
Security Scan / backend-security (push) Has been cancelled
Security Scan / frontend-security (push) Has been cancelled
fix logger and redeem admin review findings
2026-04-20 11:24:36 +08:00

468 lines
18 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/common/LoadingSpinner.vue'
import Icon from '@/components/icons/Icon.vue'
import ConfirmDialog from '@/components/common/ConfirmDialog.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')
// Confirm dialog state
const showConfirmDialog = ref(false)
const confirmDialogMessage = ref('')
const pendingClearUserId = ref<number | null>(null)
// 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)
}
}
// Confirm dialog handlers
function confirmClearStorage(userId: number) {
pendingClearUserId.value = userId
confirmDialogMessage.value = t('admin.sora.confirmClearStorage')
showConfirmDialog.value = true
}
async function handleConfirmClear() {
if (pendingClearUserId.value === null) return
const userId = pendingClearUserId.value
showConfirmDialog.value = false
pendingClearUserId.value = null
try {
await soraAdminAPI.clearUserStorage(userId)
await fetchUserStats()
} catch (err) {
console.error('Failed to clear user storage:', err)
alert(t('common.error'))
}
}
function handleCancelClear() {
showConfirmDialog.value = false
pendingClearUserId.value = null
}
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="chartBar" 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="refresh" 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"
/>
<button class="btn btn-primary" @click="fetchUserStats">{{ t('common.search') }}</button>
</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">
<button
class="btn btn-danger btn-sm"
@click="confirmClearStorage(user.user_id)"
>
{{ t('admin.sora.clearStorage') }}
</button>
</td>
</tr>
</tbody>
</table>
</div>
<!-- Pagination -->
<div v-if="userTotalPages > 1" class="flex items-center justify-center gap-2">
<button
v-for="p in userTotalPages"
:key="p"
:class="['btn btn-sm', p === userPage ? 'btn-primary' : 'btn-secondary']"
@click="onUserPageChange(p)"
>
{{ p }}
</button>
</div>
</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 -->
<div v-if="genTotalPages > 1" class="flex items-center justify-center gap-2">
<button
v-for="p in genTotalPages"
:key="p"
:class="['btn btn-sm', p === genPage ? 'btn-primary' : 'btn-secondary']"
@click="onGenPageChange(p)"
>
{{ p }}
</button>
</div>
</div>
</template>
</div>
<!-- Confirm Dialog -->
<ConfirmDialog
:show="showConfirmDialog"
:title="t('admin.sora.clearStorage')"
:message="confirmDialogMessage"
:danger="true"
@confirm="handleConfirmClear"
@cancel="handleCancelClear"
/>
</AppLayout>
</template>