@@ -50,6 +50,7 @@ export interface TrendParams {
|
||||
account_id?: number
|
||||
group_id?: number
|
||||
stream?: boolean
|
||||
billing_type?: number | null
|
||||
}
|
||||
|
||||
export interface TrendResponse {
|
||||
@@ -78,6 +79,7 @@ export interface ModelStatsParams {
|
||||
account_id?: number
|
||||
group_id?: number
|
||||
stream?: boolean
|
||||
billing_type?: number | null
|
||||
}
|
||||
|
||||
export interface ModelStatsResponse {
|
||||
|
||||
@@ -31,6 +31,46 @@ export interface SimpleApiKey {
|
||||
user_id: number
|
||||
}
|
||||
|
||||
export interface UsageCleanupFilters {
|
||||
start_time: string
|
||||
end_time: string
|
||||
user_id?: number
|
||||
api_key_id?: number
|
||||
account_id?: number
|
||||
group_id?: number
|
||||
model?: string | null
|
||||
stream?: boolean | null
|
||||
billing_type?: number | null
|
||||
}
|
||||
|
||||
export interface UsageCleanupTask {
|
||||
id: number
|
||||
status: string
|
||||
filters: UsageCleanupFilters
|
||||
created_by: number
|
||||
deleted_rows: number
|
||||
error_message?: string | null
|
||||
canceled_by?: number | null
|
||||
canceled_at?: string | null
|
||||
started_at?: string | null
|
||||
finished_at?: string | null
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export interface CreateUsageCleanupTaskRequest {
|
||||
start_date: string
|
||||
end_date: string
|
||||
user_id?: number
|
||||
api_key_id?: number
|
||||
account_id?: number
|
||||
group_id?: number
|
||||
model?: string | null
|
||||
stream?: boolean | null
|
||||
billing_type?: number | null
|
||||
timezone?: string
|
||||
}
|
||||
|
||||
export interface AdminUsageQueryParams extends UsageQueryParams {
|
||||
user_id?: number
|
||||
}
|
||||
@@ -108,11 +148,51 @@ export async function searchApiKeys(userId?: number, keyword?: string): Promise<
|
||||
return data
|
||||
}
|
||||
|
||||
/**
|
||||
* List usage cleanup tasks (admin only)
|
||||
* @param params - Query parameters for pagination
|
||||
* @returns Paginated list of cleanup tasks
|
||||
*/
|
||||
export async function listCleanupTasks(
|
||||
params: { page?: number; page_size?: number },
|
||||
options?: { signal?: AbortSignal }
|
||||
): Promise<PaginatedResponse<UsageCleanupTask>> {
|
||||
const { data } = await apiClient.get<PaginatedResponse<UsageCleanupTask>>('/admin/usage/cleanup-tasks', {
|
||||
params,
|
||||
signal: options?.signal
|
||||
})
|
||||
return data
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a usage cleanup task (admin only)
|
||||
* @param payload - Cleanup task parameters
|
||||
* @returns Created cleanup task
|
||||
*/
|
||||
export async function createCleanupTask(payload: CreateUsageCleanupTaskRequest): Promise<UsageCleanupTask> {
|
||||
const { data } = await apiClient.post<UsageCleanupTask>('/admin/usage/cleanup-tasks', payload)
|
||||
return data
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel a usage cleanup task (admin only)
|
||||
* @param taskId - Task ID to cancel
|
||||
*/
|
||||
export async function cancelCleanupTask(taskId: number): Promise<{ id: number; status: string }> {
|
||||
const { data } = await apiClient.post<{ id: number; status: string }>(
|
||||
`/admin/usage/cleanup-tasks/${taskId}/cancel`
|
||||
)
|
||||
return data
|
||||
}
|
||||
|
||||
export const adminUsageAPI = {
|
||||
list,
|
||||
getStats,
|
||||
searchUsers,
|
||||
searchApiKeys
|
||||
searchApiKeys,
|
||||
listCleanupTasks,
|
||||
createCleanupTask,
|
||||
cancelCleanupTask
|
||||
}
|
||||
|
||||
export default adminUsageAPI
|
||||
|
||||
380
frontend/src/components/admin/usage/UsageCleanupDialog.vue
Normal file
380
frontend/src/components/admin/usage/UsageCleanupDialog.vue
Normal file
@@ -0,0 +1,380 @@
|
||||
<template>
|
||||
<BaseDialog :show="show" :title="t('admin.usage.cleanup.title')" width="wide" @close="handleClose">
|
||||
<div class="space-y-4">
|
||||
<UsageFilters
|
||||
v-model="localFilters"
|
||||
v-model:startDate="localStartDate"
|
||||
v-model:endDate="localEndDate"
|
||||
:exporting="false"
|
||||
:show-actions="false"
|
||||
@change="noop"
|
||||
/>
|
||||
|
||||
<div class="rounded-xl border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-700 dark:border-amber-500/30 dark:bg-amber-500/10 dark:text-amber-200">
|
||||
{{ t('admin.usage.cleanup.warning') }}
|
||||
</div>
|
||||
|
||||
<div class="rounded-xl border border-gray-200 p-4 dark:border-dark-700">
|
||||
<div class="flex items-center justify-between">
|
||||
<h4 class="text-sm font-semibold text-gray-700 dark:text-gray-200">
|
||||
{{ t('admin.usage.cleanup.recentTasks') }}
|
||||
</h4>
|
||||
<button type="button" class="btn btn-ghost btn-sm" @click="loadTasks">
|
||||
{{ t('common.refresh') }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="mt-3 space-y-2">
|
||||
<div v-if="tasksLoading" class="text-sm text-gray-500 dark:text-gray-400">
|
||||
{{ t('admin.usage.cleanup.loadingTasks') }}
|
||||
</div>
|
||||
<div v-else-if="tasks.length === 0" class="text-sm text-gray-500 dark:text-gray-400">
|
||||
{{ t('admin.usage.cleanup.noTasks') }}
|
||||
</div>
|
||||
<div v-else class="space-y-2">
|
||||
<div
|
||||
v-for="task in tasks"
|
||||
:key="task.id"
|
||||
class="flex flex-col gap-2 rounded-lg border border-gray-100 px-3 py-2 text-sm text-gray-600 dark:border-dark-700 dark:text-gray-300"
|
||||
>
|
||||
<div class="flex flex-wrap items-center justify-between gap-2">
|
||||
<div class="flex items-center gap-2">
|
||||
<span :class="statusClass(task.status)" class="rounded-full px-2 py-0.5 text-xs font-semibold">
|
||||
{{ statusLabel(task.status) }}
|
||||
</span>
|
||||
<span class="text-xs text-gray-400">#{{ task.id }}</span>
|
||||
<button
|
||||
v-if="canCancel(task)"
|
||||
type="button"
|
||||
class="btn btn-ghost btn-xs text-rose-600 hover:text-rose-700 dark:text-rose-300"
|
||||
@click="openCancelConfirm(task)"
|
||||
>
|
||||
{{ t('admin.usage.cleanup.cancel') }}
|
||||
</button>
|
||||
</div>
|
||||
<div class="text-xs text-gray-400">
|
||||
{{ formatDateTime(task.created_at) }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-wrap items-center gap-4 text-xs text-gray-500 dark:text-gray-400">
|
||||
<span>{{ t('admin.usage.cleanup.range') }}: {{ formatRange(task) }}</span>
|
||||
<span>{{ t('admin.usage.cleanup.deletedRows') }}: {{ task.deleted_rows.toLocaleString() }}</span>
|
||||
</div>
|
||||
<div v-if="task.error_message" class="text-xs text-rose-500">
|
||||
{{ task.error_message }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Pagination
|
||||
v-if="tasksTotal > tasksPageSize"
|
||||
class="mt-4"
|
||||
:total="tasksTotal"
|
||||
:page="tasksPage"
|
||||
:page-size="tasksPageSize"
|
||||
:page-size-options="[5]"
|
||||
:show-page-size-selector="false"
|
||||
:show-jump="true"
|
||||
@update:page="handleTaskPageChange"
|
||||
@update:pageSize="handleTaskPageSizeChange"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<div class="flex justify-end gap-3">
|
||||
<button type="button" class="btn btn-secondary" @click="handleClose">
|
||||
{{ t('common.cancel') }}
|
||||
</button>
|
||||
<button type="button" class="btn btn-danger" :disabled="submitting" @click="openConfirm">
|
||||
{{ submitting ? t('admin.usage.cleanup.submitting') : t('admin.usage.cleanup.submit') }}
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
</BaseDialog>
|
||||
|
||||
<ConfirmDialog
|
||||
:show="confirmVisible"
|
||||
:title="t('admin.usage.cleanup.confirmTitle')"
|
||||
:message="t('admin.usage.cleanup.confirmMessage')"
|
||||
:confirm-text="t('admin.usage.cleanup.confirmSubmit')"
|
||||
danger
|
||||
@confirm="submitCleanup"
|
||||
@cancel="confirmVisible = false"
|
||||
/>
|
||||
|
||||
<ConfirmDialog
|
||||
:show="cancelConfirmVisible"
|
||||
:title="t('admin.usage.cleanup.cancelConfirmTitle')"
|
||||
:message="t('admin.usage.cleanup.cancelConfirmMessage')"
|
||||
:confirm-text="t('admin.usage.cleanup.cancelConfirm')"
|
||||
danger
|
||||
@confirm="cancelTask"
|
||||
@cancel="cancelConfirmVisible = false"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch, onUnmounted } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
import BaseDialog from '@/components/common/BaseDialog.vue'
|
||||
import ConfirmDialog from '@/components/common/ConfirmDialog.vue'
|
||||
import Pagination from '@/components/common/Pagination.vue'
|
||||
import UsageFilters from '@/components/admin/usage/UsageFilters.vue'
|
||||
import { adminUsageAPI } from '@/api/admin/usage'
|
||||
import type { AdminUsageQueryParams, UsageCleanupTask, CreateUsageCleanupTaskRequest } from '@/api/admin/usage'
|
||||
|
||||
interface Props {
|
||||
show: boolean
|
||||
filters: AdminUsageQueryParams
|
||||
startDate: string
|
||||
endDate: string
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
const emit = defineEmits(['close'])
|
||||
|
||||
const { t } = useI18n()
|
||||
const appStore = useAppStore()
|
||||
|
||||
const localFilters = ref<AdminUsageQueryParams>({})
|
||||
const localStartDate = ref('')
|
||||
const localEndDate = ref('')
|
||||
|
||||
const tasks = ref<UsageCleanupTask[]>([])
|
||||
const tasksLoading = ref(false)
|
||||
const tasksPage = ref(1)
|
||||
const tasksPageSize = ref(5)
|
||||
const tasksTotal = ref(0)
|
||||
const submitting = ref(false)
|
||||
const confirmVisible = ref(false)
|
||||
const cancelConfirmVisible = ref(false)
|
||||
const canceling = ref(false)
|
||||
const cancelTarget = ref<UsageCleanupTask | null>(null)
|
||||
let pollTimer: number | null = null
|
||||
|
||||
const noop = () => {}
|
||||
|
||||
const resetFilters = () => {
|
||||
localFilters.value = { ...props.filters }
|
||||
localStartDate.value = props.startDate
|
||||
localEndDate.value = props.endDate
|
||||
localFilters.value.start_date = localStartDate.value
|
||||
localFilters.value.end_date = localEndDate.value
|
||||
tasksPage.value = 1
|
||||
tasksTotal.value = 0
|
||||
}
|
||||
|
||||
const startPolling = () => {
|
||||
stopPolling()
|
||||
pollTimer = window.setInterval(() => {
|
||||
loadTasks()
|
||||
}, 10000)
|
||||
}
|
||||
|
||||
const stopPolling = () => {
|
||||
if (pollTimer !== null) {
|
||||
window.clearInterval(pollTimer)
|
||||
pollTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
const handleClose = () => {
|
||||
stopPolling()
|
||||
confirmVisible.value = false
|
||||
cancelConfirmVisible.value = false
|
||||
canceling.value = false
|
||||
cancelTarget.value = null
|
||||
submitting.value = false
|
||||
emit('close')
|
||||
}
|
||||
|
||||
const statusLabel = (status: string) => {
|
||||
const map: Record<string, string> = {
|
||||
pending: t('admin.usage.cleanup.status.pending'),
|
||||
running: t('admin.usage.cleanup.status.running'),
|
||||
succeeded: t('admin.usage.cleanup.status.succeeded'),
|
||||
failed: t('admin.usage.cleanup.status.failed'),
|
||||
canceled: t('admin.usage.cleanup.status.canceled')
|
||||
}
|
||||
return map[status] || status
|
||||
}
|
||||
|
||||
const statusClass = (status: string) => {
|
||||
const map: Record<string, string> = {
|
||||
pending: 'bg-amber-100 text-amber-700 dark:bg-amber-500/20 dark:text-amber-200',
|
||||
running: 'bg-blue-100 text-blue-700 dark:bg-blue-500/20 dark:text-blue-200',
|
||||
succeeded: 'bg-emerald-100 text-emerald-700 dark:bg-emerald-500/20 dark:text-emerald-200',
|
||||
failed: 'bg-rose-100 text-rose-700 dark:bg-rose-500/20 dark:text-rose-200',
|
||||
canceled: 'bg-gray-200 text-gray-600 dark:bg-dark-600 dark:text-gray-300'
|
||||
}
|
||||
return map[status] || 'bg-gray-100 text-gray-600'
|
||||
}
|
||||
|
||||
const formatDateTime = (value?: string | null) => {
|
||||
if (!value) return '--'
|
||||
const date = new Date(value)
|
||||
if (Number.isNaN(date.getTime())) return value
|
||||
return date.toLocaleString()
|
||||
}
|
||||
|
||||
const formatRange = (task: UsageCleanupTask) => {
|
||||
const start = formatDateTime(task.filters.start_time)
|
||||
const end = formatDateTime(task.filters.end_time)
|
||||
return `${start} ~ ${end}`
|
||||
}
|
||||
|
||||
const getUserTimezone = () => {
|
||||
try {
|
||||
return Intl.DateTimeFormat().resolvedOptions().timeZone
|
||||
} catch {
|
||||
return 'UTC'
|
||||
}
|
||||
}
|
||||
|
||||
const loadTasks = async () => {
|
||||
if (!props.show) return
|
||||
tasksLoading.value = true
|
||||
try {
|
||||
const res = await adminUsageAPI.listCleanupTasks({
|
||||
page: tasksPage.value,
|
||||
page_size: tasksPageSize.value
|
||||
})
|
||||
tasks.value = res.items || []
|
||||
tasksTotal.value = res.total || 0
|
||||
if (res.page) {
|
||||
tasksPage.value = res.page
|
||||
}
|
||||
if (res.page_size) {
|
||||
tasksPageSize.value = res.page_size
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load cleanup tasks:', error)
|
||||
appStore.showError(t('admin.usage.cleanup.loadFailed'))
|
||||
} finally {
|
||||
tasksLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleTaskPageChange = (page: number) => {
|
||||
tasksPage.value = page
|
||||
loadTasks()
|
||||
}
|
||||
|
||||
const handleTaskPageSizeChange = (size: number) => {
|
||||
if (!Number.isFinite(size) || size <= 0) return
|
||||
tasksPageSize.value = size
|
||||
tasksPage.value = 1
|
||||
loadTasks()
|
||||
}
|
||||
|
||||
const openConfirm = () => {
|
||||
confirmVisible.value = true
|
||||
}
|
||||
|
||||
const canCancel = (task: UsageCleanupTask) => {
|
||||
return task.status === 'pending' || task.status === 'running'
|
||||
}
|
||||
|
||||
const openCancelConfirm = (task: UsageCleanupTask) => {
|
||||
cancelTarget.value = task
|
||||
cancelConfirmVisible.value = true
|
||||
}
|
||||
|
||||
const buildPayload = (): CreateUsageCleanupTaskRequest | null => {
|
||||
if (!localStartDate.value || !localEndDate.value) {
|
||||
appStore.showError(t('admin.usage.cleanup.missingRange'))
|
||||
return null
|
||||
}
|
||||
|
||||
const payload: CreateUsageCleanupTaskRequest = {
|
||||
start_date: localStartDate.value,
|
||||
end_date: localEndDate.value,
|
||||
timezone: getUserTimezone()
|
||||
}
|
||||
|
||||
if (localFilters.value.user_id && localFilters.value.user_id > 0) {
|
||||
payload.user_id = localFilters.value.user_id
|
||||
}
|
||||
if (localFilters.value.api_key_id && localFilters.value.api_key_id > 0) {
|
||||
payload.api_key_id = localFilters.value.api_key_id
|
||||
}
|
||||
if (localFilters.value.account_id && localFilters.value.account_id > 0) {
|
||||
payload.account_id = localFilters.value.account_id
|
||||
}
|
||||
if (localFilters.value.group_id && localFilters.value.group_id > 0) {
|
||||
payload.group_id = localFilters.value.group_id
|
||||
}
|
||||
if (localFilters.value.model) {
|
||||
payload.model = localFilters.value.model
|
||||
}
|
||||
if (localFilters.value.stream !== null && localFilters.value.stream !== undefined) {
|
||||
payload.stream = localFilters.value.stream
|
||||
}
|
||||
if (localFilters.value.billing_type !== null && localFilters.value.billing_type !== undefined) {
|
||||
payload.billing_type = localFilters.value.billing_type
|
||||
}
|
||||
|
||||
return payload
|
||||
}
|
||||
|
||||
const submitCleanup = async () => {
|
||||
const payload = buildPayload()
|
||||
if (!payload) {
|
||||
confirmVisible.value = false
|
||||
return
|
||||
}
|
||||
submitting.value = true
|
||||
confirmVisible.value = false
|
||||
try {
|
||||
await adminUsageAPI.createCleanupTask(payload)
|
||||
appStore.showSuccess(t('admin.usage.cleanup.submitSuccess'))
|
||||
loadTasks()
|
||||
} catch (error) {
|
||||
console.error('Failed to create cleanup task:', error)
|
||||
appStore.showError(t('admin.usage.cleanup.submitFailed'))
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const cancelTask = async () => {
|
||||
const task = cancelTarget.value
|
||||
if (!task) {
|
||||
cancelConfirmVisible.value = false
|
||||
return
|
||||
}
|
||||
canceling.value = true
|
||||
cancelConfirmVisible.value = false
|
||||
try {
|
||||
await adminUsageAPI.cancelCleanupTask(task.id)
|
||||
appStore.showSuccess(t('admin.usage.cleanup.cancelSuccess'))
|
||||
loadTasks()
|
||||
} catch (error) {
|
||||
console.error('Failed to cancel cleanup task:', error)
|
||||
appStore.showError(t('admin.usage.cleanup.cancelFailed'))
|
||||
} finally {
|
||||
canceling.value = false
|
||||
cancelTarget.value = null
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.show,
|
||||
(show) => {
|
||||
if (show) {
|
||||
resetFilters()
|
||||
loadTasks()
|
||||
startPolling()
|
||||
} else {
|
||||
stopPolling()
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
onUnmounted(() => {
|
||||
stopPolling()
|
||||
})
|
||||
</script>
|
||||
@@ -127,6 +127,12 @@
|
||||
<Select v-model="filters.stream" :options="streamTypeOptions" @change="emitChange" />
|
||||
</div>
|
||||
|
||||
<!-- Billing Type Filter -->
|
||||
<div class="w-full sm:w-auto sm:min-w-[200px]">
|
||||
<label class="input-label">{{ t('admin.usage.billingType') }}</label>
|
||||
<Select v-model="filters.billing_type" :options="billingTypeOptions" @change="emitChange" />
|
||||
</div>
|
||||
|
||||
<!-- Group Filter -->
|
||||
<div class="w-full sm:w-auto sm:min-w-[200px]">
|
||||
<label class="input-label">{{ t('admin.usage.group') }}</label>
|
||||
@@ -147,10 +153,13 @@
|
||||
</div>
|
||||
|
||||
<!-- Right: actions -->
|
||||
<div class="flex w-full flex-wrap items-center justify-end gap-3 sm:w-auto">
|
||||
<div v-if="showActions" class="flex w-full flex-wrap items-center justify-end gap-3 sm:w-auto">
|
||||
<button type="button" @click="$emit('reset')" class="btn btn-secondary">
|
||||
{{ t('common.reset') }}
|
||||
</button>
|
||||
<button type="button" @click="$emit('cleanup')" class="btn btn-danger">
|
||||
{{ t('admin.usage.cleanup.button') }}
|
||||
</button>
|
||||
<button type="button" @click="$emit('export')" :disabled="exporting" class="btn btn-primary">
|
||||
{{ t('usage.exportExcel') }}
|
||||
</button>
|
||||
@@ -174,16 +183,20 @@ interface Props {
|
||||
exporting: boolean
|
||||
startDate: string
|
||||
endDate: string
|
||||
showActions?: boolean
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
showActions: true
|
||||
})
|
||||
const emit = defineEmits([
|
||||
'update:modelValue',
|
||||
'update:startDate',
|
||||
'update:endDate',
|
||||
'change',
|
||||
'reset',
|
||||
'export'
|
||||
'export',
|
||||
'cleanup'
|
||||
])
|
||||
|
||||
const { t } = useI18n()
|
||||
@@ -221,6 +234,12 @@ const streamTypeOptions = ref<SelectOption[]>([
|
||||
{ value: false, label: t('usage.sync') }
|
||||
])
|
||||
|
||||
const billingTypeOptions = ref<SelectOption[]>([
|
||||
{ value: null, label: t('admin.usage.allBillingTypes') },
|
||||
{ value: 0, label: t('admin.usage.billingTypeBalance') },
|
||||
{ value: 1, label: t('admin.usage.billingTypeSubscription') }
|
||||
])
|
||||
|
||||
const emitChange = () => emit('change')
|
||||
|
||||
const updateStartDate = (value: string) => {
|
||||
|
||||
@@ -37,7 +37,7 @@
|
||||
</p>
|
||||
|
||||
<!-- Page size selector -->
|
||||
<div class="flex items-center space-x-2">
|
||||
<div v-if="showPageSizeSelector" class="flex items-center space-x-2">
|
||||
<span class="text-sm text-gray-700 dark:text-gray-300"
|
||||
>{{ t('pagination.perPage') }}:</span
|
||||
>
|
||||
@@ -49,6 +49,22 @@
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="showJump" class="flex items-center space-x-2">
|
||||
<span class="text-sm text-gray-700 dark:text-gray-300">{{ t('pagination.jumpTo') }}</span>
|
||||
<input
|
||||
v-model="jumpPage"
|
||||
type="number"
|
||||
min="1"
|
||||
:max="totalPages"
|
||||
class="input w-20 text-sm"
|
||||
:placeholder="t('pagination.jumpPlaceholder')"
|
||||
@keyup.enter="submitJump"
|
||||
/>
|
||||
<button type="button" class="btn btn-ghost btn-sm" @click="submitJump">
|
||||
{{ t('pagination.jumpAction') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Desktop pagination buttons -->
|
||||
@@ -102,7 +118,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { computed, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import Icon from '@/components/icons/Icon.vue'
|
||||
import Select from './Select.vue'
|
||||
@@ -114,6 +130,8 @@ interface Props {
|
||||
page: number
|
||||
pageSize: number
|
||||
pageSizeOptions?: number[]
|
||||
showPageSizeSelector?: boolean
|
||||
showJump?: boolean
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
@@ -122,7 +140,9 @@ interface Emits {
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
pageSizeOptions: () => [10, 20, 50, 100]
|
||||
pageSizeOptions: () => [10, 20, 50, 100],
|
||||
showPageSizeSelector: true,
|
||||
showJump: false
|
||||
})
|
||||
|
||||
const emit = defineEmits<Emits>()
|
||||
@@ -146,6 +166,8 @@ const pageSizeSelectOptions = computed(() => {
|
||||
}))
|
||||
})
|
||||
|
||||
const jumpPage = ref('')
|
||||
|
||||
const visiblePages = computed(() => {
|
||||
const pages: (number | string)[] = []
|
||||
const maxVisible = 7
|
||||
@@ -196,6 +218,16 @@ const handlePageSizeChange = (value: string | number | boolean | null) => {
|
||||
const newPageSize = typeof value === 'string' ? parseInt(value) : value
|
||||
emit('update:pageSize', newPageSize)
|
||||
}
|
||||
|
||||
const submitJump = () => {
|
||||
const value = jumpPage.value.trim()
|
||||
if (!value) return
|
||||
const pageNum = Number.parseInt(value, 10)
|
||||
if (Number.isNaN(pageNum)) return
|
||||
const nextPage = Math.min(Math.max(pageNum, 1), totalPages.value)
|
||||
jumpPage.value = ''
|
||||
goToPage(nextPage)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -573,7 +573,10 @@ export default {
|
||||
previous: 'Previous',
|
||||
next: 'Next',
|
||||
perPage: 'Per page',
|
||||
goToPage: 'Go to page {page}'
|
||||
goToPage: 'Go to page {page}',
|
||||
jumpTo: 'Jump to',
|
||||
jumpPlaceholder: 'Page',
|
||||
jumpAction: 'Go'
|
||||
},
|
||||
|
||||
// Errors
|
||||
@@ -1938,7 +1941,43 @@ export default {
|
||||
cacheCreationTokens: 'Cache Creation Tokens',
|
||||
cacheReadTokens: 'Cache Read Tokens',
|
||||
failedToLoad: 'Failed to load usage records',
|
||||
ipAddress: 'IP'
|
||||
billingType: 'Billing Type',
|
||||
allBillingTypes: 'All Billing Types',
|
||||
billingTypeBalance: 'Balance',
|
||||
billingTypeSubscription: 'Subscription',
|
||||
ipAddress: 'IP',
|
||||
cleanup: {
|
||||
button: 'Cleanup',
|
||||
title: 'Cleanup Usage Records',
|
||||
warning: 'Cleanup is irreversible and will affect historical stats.',
|
||||
submit: 'Submit Cleanup',
|
||||
submitting: 'Submitting...',
|
||||
confirmTitle: 'Confirm Cleanup',
|
||||
confirmMessage: 'Are you sure you want to submit this cleanup task? This action cannot be undone.',
|
||||
confirmSubmit: 'Confirm Cleanup',
|
||||
cancel: 'Cancel',
|
||||
cancelConfirmTitle: 'Confirm Cancel',
|
||||
cancelConfirmMessage: 'Are you sure you want to cancel this cleanup task?',
|
||||
cancelConfirm: 'Confirm Cancel',
|
||||
cancelSuccess: 'Cleanup task canceled',
|
||||
cancelFailed: 'Failed to cancel cleanup task',
|
||||
recentTasks: 'Recent Cleanup Tasks',
|
||||
loadingTasks: 'Loading tasks...',
|
||||
noTasks: 'No cleanup tasks yet',
|
||||
range: 'Range',
|
||||
deletedRows: 'Deleted',
|
||||
missingRange: 'Please select a date range',
|
||||
submitSuccess: 'Cleanup task created',
|
||||
submitFailed: 'Failed to create cleanup task',
|
||||
loadFailed: 'Failed to load cleanup tasks',
|
||||
status: {
|
||||
pending: 'Pending',
|
||||
running: 'Running',
|
||||
succeeded: 'Succeeded',
|
||||
failed: 'Failed',
|
||||
canceled: 'Canceled'
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
// Ops Monitoring
|
||||
|
||||
@@ -569,7 +569,10 @@ export default {
|
||||
previous: '上一页',
|
||||
next: '下一页',
|
||||
perPage: '每页',
|
||||
goToPage: '跳转到第 {page} 页'
|
||||
goToPage: '跳转到第 {page} 页',
|
||||
jumpTo: '跳转页',
|
||||
jumpPlaceholder: '页码',
|
||||
jumpAction: '跳转'
|
||||
},
|
||||
|
||||
// Errors
|
||||
@@ -2085,7 +2088,43 @@ export default {
|
||||
cacheCreationTokens: '缓存创建 Token',
|
||||
cacheReadTokens: '缓存读取 Token',
|
||||
failedToLoad: '加载使用记录失败',
|
||||
ipAddress: 'IP'
|
||||
billingType: '计费类型',
|
||||
allBillingTypes: '全部计费类型',
|
||||
billingTypeBalance: '钱包余额',
|
||||
billingTypeSubscription: '订阅套餐',
|
||||
ipAddress: 'IP',
|
||||
cleanup: {
|
||||
button: '清理',
|
||||
title: '清理使用记录',
|
||||
warning: '清理不可恢复,且会影响历史统计回看。',
|
||||
submit: '提交清理',
|
||||
submitting: '提交中...',
|
||||
confirmTitle: '确认清理',
|
||||
confirmMessage: '确定要提交清理任务吗?清理不可恢复。',
|
||||
confirmSubmit: '确认清理',
|
||||
cancel: '取消任务',
|
||||
cancelConfirmTitle: '确认取消',
|
||||
cancelConfirmMessage: '确定要取消该清理任务吗?',
|
||||
cancelConfirm: '确认取消',
|
||||
cancelSuccess: '清理任务已取消',
|
||||
cancelFailed: '取消清理任务失败',
|
||||
recentTasks: '最近清理任务',
|
||||
loadingTasks: '正在加载任务...',
|
||||
noTasks: '暂无清理任务',
|
||||
range: '时间范围',
|
||||
deletedRows: '删除数量',
|
||||
missingRange: '请选择时间范围',
|
||||
submitSuccess: '清理任务已创建',
|
||||
submitFailed: '创建清理任务失败',
|
||||
loadFailed: '加载清理任务失败',
|
||||
status: {
|
||||
pending: '待执行',
|
||||
running: '执行中',
|
||||
succeeded: '已完成',
|
||||
failed: '失败',
|
||||
canceled: '已取消'
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
// Ops Monitoring
|
||||
|
||||
@@ -633,6 +633,7 @@ export interface UsageLog {
|
||||
actual_cost: number
|
||||
rate_multiplier: number
|
||||
account_rate_multiplier?: number | null
|
||||
billing_type: number
|
||||
|
||||
stream: boolean
|
||||
duration_ms: number
|
||||
@@ -657,6 +658,33 @@ export interface UsageLog {
|
||||
subscription?: UserSubscription
|
||||
}
|
||||
|
||||
export interface UsageCleanupFilters {
|
||||
start_time: string
|
||||
end_time: string
|
||||
user_id?: number
|
||||
api_key_id?: number
|
||||
account_id?: number
|
||||
group_id?: number
|
||||
model?: string | null
|
||||
stream?: boolean | null
|
||||
billing_type?: number | null
|
||||
}
|
||||
|
||||
export interface UsageCleanupTask {
|
||||
id: number
|
||||
status: string
|
||||
filters: UsageCleanupFilters
|
||||
created_by: number
|
||||
deleted_rows: number
|
||||
error_message?: string | null
|
||||
canceled_by?: number | null
|
||||
canceled_at?: string | null
|
||||
started_at?: string | null
|
||||
finished_at?: string | null
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export interface RedeemCode {
|
||||
id: number
|
||||
code: string
|
||||
@@ -880,6 +908,7 @@ export interface UsageQueryParams {
|
||||
group_id?: number
|
||||
model?: string
|
||||
stream?: boolean
|
||||
billing_type?: number | null
|
||||
start_date?: string
|
||||
end_date?: string
|
||||
}
|
||||
|
||||
@@ -17,12 +17,19 @@
|
||||
<TokenUsageTrend :trend-data="trendData" :loading="chartsLoading" />
|
||||
</div>
|
||||
</div>
|
||||
<UsageFilters v-model="filters" v-model:startDate="startDate" v-model:endDate="endDate" :exporting="exporting" @change="applyFilters" @reset="resetFilters" @export="exportToExcel" />
|
||||
<UsageFilters v-model="filters" v-model:startDate="startDate" v-model:endDate="endDate" :exporting="exporting" @change="applyFilters" @reset="resetFilters" @cleanup="openCleanupDialog" @export="exportToExcel" />
|
||||
<UsageTable :data="usageLogs" :loading="loading" />
|
||||
<Pagination v-if="pagination.total > 0" :page="pagination.page" :total="pagination.total" :page-size="pagination.page_size" @update:page="handlePageChange" @update:pageSize="handlePageSizeChange" />
|
||||
</div>
|
||||
</AppLayout>
|
||||
<UsageExportProgress :show="exportProgress.show" :progress="exportProgress.progress" :current="exportProgress.current" :total="exportProgress.total" :estimated-time="exportProgress.estimatedTime" @cancel="cancelExport" />
|
||||
<UsageCleanupDialog
|
||||
:show="cleanupDialogVisible"
|
||||
:filters="filters"
|
||||
:start-date="startDate"
|
||||
:end-date="endDate"
|
||||
@close="cleanupDialogVisible = false"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
@@ -33,6 +40,7 @@ import { useAppStore } from '@/stores/app'; import { adminAPI } from '@/api/admi
|
||||
import AppLayout from '@/components/layout/AppLayout.vue'; import Pagination from '@/components/common/Pagination.vue'; import Select from '@/components/common/Select.vue'
|
||||
import UsageStatsCards from '@/components/admin/usage/UsageStatsCards.vue'; import UsageFilters from '@/components/admin/usage/UsageFilters.vue'
|
||||
import UsageTable from '@/components/admin/usage/UsageTable.vue'; import UsageExportProgress from '@/components/admin/usage/UsageExportProgress.vue'
|
||||
import UsageCleanupDialog from '@/components/admin/usage/UsageCleanupDialog.vue'
|
||||
import ModelDistributionChart from '@/components/charts/ModelDistributionChart.vue'; import TokenUsageTrend from '@/components/charts/TokenUsageTrend.vue'
|
||||
import type { UsageLog, TrendDataPoint, ModelStat } from '@/types'; import type { AdminUsageStatsResponse, AdminUsageQueryParams } from '@/api/admin/usage'
|
||||
|
||||
@@ -42,6 +50,7 @@ const usageStats = ref<AdminUsageStatsResponse | null>(null); const usageLogs =
|
||||
const trendData = ref<TrendDataPoint[]>([]); const modelStats = ref<ModelStat[]>([]); const chartsLoading = ref(false); const granularity = ref<'day' | 'hour'>('day')
|
||||
let abortController: AbortController | null = null; let exportAbortController: AbortController | null = null
|
||||
const exportProgress = reactive({ show: false, progress: 0, current: 0, total: 0, estimatedTime: '' })
|
||||
const cleanupDialogVisible = ref(false)
|
||||
|
||||
const granularityOptions = computed(() => [{ value: 'day', label: t('admin.dashboard.day') }, { value: 'hour', label: t('admin.dashboard.hour') }])
|
||||
// Use local timezone to avoid UTC timezone issues
|
||||
@@ -53,7 +62,7 @@ const formatLD = (d: Date) => {
|
||||
}
|
||||
const now = new Date(); const weekAgo = new Date(); weekAgo.setDate(weekAgo.getDate() - 6)
|
||||
const startDate = ref(formatLD(weekAgo)); const endDate = ref(formatLD(now))
|
||||
const filters = ref<AdminUsageQueryParams>({ user_id: undefined, model: undefined, group_id: undefined, start_date: startDate.value, end_date: endDate.value })
|
||||
const filters = ref<AdminUsageQueryParams>({ user_id: undefined, model: undefined, group_id: undefined, billing_type: null, start_date: startDate.value, end_date: endDate.value })
|
||||
const pagination = reactive({ page: 1, page_size: 20, total: 0 })
|
||||
|
||||
const loadLogs = async () => {
|
||||
@@ -67,16 +76,17 @@ const loadStats = async () => { try { const s = await adminAPI.usage.getStats(fi
|
||||
const loadChartData = async () => {
|
||||
chartsLoading.value = true
|
||||
try {
|
||||
const params = { start_date: filters.value.start_date || startDate.value, end_date: filters.value.end_date || endDate.value, granularity: granularity.value, user_id: filters.value.user_id, model: filters.value.model, api_key_id: filters.value.api_key_id, account_id: filters.value.account_id, group_id: filters.value.group_id, stream: filters.value.stream }
|
||||
const [trendRes, modelRes] = await Promise.all([adminAPI.dashboard.getUsageTrend(params), adminAPI.dashboard.getModelStats({ start_date: params.start_date, end_date: params.end_date, user_id: params.user_id, model: params.model, api_key_id: params.api_key_id, account_id: params.account_id, group_id: params.group_id, stream: params.stream })])
|
||||
const params = { start_date: filters.value.start_date || startDate.value, end_date: filters.value.end_date || endDate.value, granularity: granularity.value, user_id: filters.value.user_id, model: filters.value.model, api_key_id: filters.value.api_key_id, account_id: filters.value.account_id, group_id: filters.value.group_id, stream: filters.value.stream, billing_type: filters.value.billing_type }
|
||||
const [trendRes, modelRes] = await Promise.all([adminAPI.dashboard.getUsageTrend(params), adminAPI.dashboard.getModelStats({ start_date: params.start_date, end_date: params.end_date, user_id: params.user_id, model: params.model, api_key_id: params.api_key_id, account_id: params.account_id, group_id: params.group_id, stream: params.stream, billing_type: params.billing_type })])
|
||||
trendData.value = trendRes.trend || []; modelStats.value = modelRes.models || []
|
||||
} catch (error) { console.error('Failed to load chart data:', error) } finally { chartsLoading.value = false }
|
||||
}
|
||||
const applyFilters = () => { pagination.page = 1; loadLogs(); loadStats(); loadChartData() }
|
||||
const resetFilters = () => { startDate.value = formatLD(weekAgo); endDate.value = formatLD(now); filters.value = { start_date: startDate.value, end_date: endDate.value }; granularity.value = 'day'; applyFilters() }
|
||||
const resetFilters = () => { startDate.value = formatLD(weekAgo); endDate.value = formatLD(now); filters.value = { start_date: startDate.value, end_date: endDate.value, billing_type: null }; granularity.value = 'day'; applyFilters() }
|
||||
const handlePageChange = (p: number) => { pagination.page = p; loadLogs() }
|
||||
const handlePageSizeChange = (s: number) => { pagination.page_size = s; pagination.page = 1; loadLogs() }
|
||||
const cancelExport = () => exportAbortController?.abort()
|
||||
const openCleanupDialog = () => { cleanupDialogVisible.value = true }
|
||||
|
||||
const exportToExcel = async () => {
|
||||
if (exporting.value) return; exporting.value = true; exportProgress.show = true
|
||||
|
||||
Reference in New Issue
Block a user