381 lines
12 KiB
Vue
381 lines
12 KiB
Vue
<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>
|