feat(usage): 添加清理任务与统计过滤
This commit is contained in:
339
frontend/src/components/admin/usage/UsageCleanupDialog.vue
Normal file
339
frontend/src/components/admin/usage/UsageCleanupDialog.vue
Normal file
@@ -0,0 +1,339 @@
|
||||
<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>
|
||||
</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 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 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
|
||||
}
|
||||
|
||||
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: 1, page_size: 10 })
|
||||
tasks.value = res.items || []
|
||||
} catch (error) {
|
||||
console.error('Failed to load cleanup tasks:', error)
|
||||
appStore.showError(t('admin.usage.cleanup.loadFailed'))
|
||||
} finally {
|
||||
tasksLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
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>
|
||||
Reference in New Issue
Block a user