You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
255 lines
7.4 KiB
255 lines
7.4 KiB
<script setup lang="ts">
|
|
import type { ExportMaskPolicy, ExportTaskItem } from '../../../types/export'
|
|
|
|
usePageTitle('数据导出')
|
|
|
|
const toast = useToast()
|
|
const { fetchData, getApiErrorMessage } = useClientApi()
|
|
|
|
const maskPolicy = ref<ExportMaskPolicy>('masked')
|
|
const creating = ref(false)
|
|
const loading = ref(true)
|
|
const refreshing = ref(false)
|
|
const tasks = ref<ExportTaskItem[]>([])
|
|
|
|
const maskPolicyItems = [
|
|
{ label: 'masked(默认,脱敏导出)', value: 'masked' as const },
|
|
{ label: 'raw(原始值导出)', value: 'raw' as const },
|
|
]
|
|
|
|
const statusToneMap: Record<ExportTaskItem['status'], 'neutral' | 'primary' | 'success' | 'error' | 'warning'> = {
|
|
queued: 'neutral',
|
|
running: 'primary',
|
|
succeeded: 'success',
|
|
failed: 'error',
|
|
expired: 'warning',
|
|
}
|
|
|
|
const statusLabelMap: Record<ExportTaskItem['status'], string> = {
|
|
queued: '排队中',
|
|
running: '处理中',
|
|
succeeded: '已完成',
|
|
failed: '失败',
|
|
expired: '已过期',
|
|
}
|
|
|
|
function formatTime(iso: string | null): string {
|
|
if (!iso) {
|
|
return '—'
|
|
}
|
|
const dt = new Date(iso)
|
|
return Number.isNaN(dt.getTime()) ? iso : dt.toLocaleString('zh-CN')
|
|
}
|
|
|
|
function formatBytes(n: number | null): string {
|
|
if (n === null || Number.isNaN(n)) {
|
|
return '—'
|
|
}
|
|
if (n < 1024) {
|
|
return `${n} B`
|
|
}
|
|
if (n < 1024 * 1024) {
|
|
return `${(n / 1024).toFixed(1)} KB`
|
|
}
|
|
return `${(n / (1024 * 1024)).toFixed(1)} MB`
|
|
}
|
|
|
|
function taskDownloadUrl(taskId: number): string {
|
|
return `/api/me/export/tasks/${taskId}/download`
|
|
}
|
|
|
|
function openExportDownload(taskId: number) {
|
|
window.open(taskDownloadUrl(taskId), '_blank', 'noopener,noreferrer')
|
|
}
|
|
|
|
async function loadTasks() {
|
|
const useBlocking = tasks.value.length === 0
|
|
if (useBlocking) {
|
|
loading.value = true
|
|
} else {
|
|
refreshing.value = true
|
|
}
|
|
try {
|
|
const res = await fetchData<{ items: ExportTaskItem[] }>('/api/me/export/tasks', { notify: false })
|
|
tasks.value = res.items
|
|
} catch (e: unknown) {
|
|
toast.add({ title: getApiErrorMessage(e), color: 'error' })
|
|
} finally {
|
|
loading.value = false
|
|
refreshing.value = false
|
|
}
|
|
}
|
|
|
|
async function createTask() {
|
|
if (creating.value) {
|
|
return
|
|
}
|
|
creating.value = true
|
|
try {
|
|
await fetchData('/api/me/export/request', {
|
|
method: 'POST',
|
|
body: { maskPolicy: maskPolicy.value },
|
|
notify: false,
|
|
})
|
|
toast.add({ title: '导出任务已创建', color: 'success' })
|
|
await loadTasks()
|
|
} catch (e: unknown) {
|
|
toast.add({ title: getApiErrorMessage(e), color: 'error' })
|
|
} finally {
|
|
creating.value = false
|
|
}
|
|
}
|
|
|
|
async function recreateTask(task: ExportTaskItem) {
|
|
maskPolicy.value = task.maskPolicy
|
|
await createTask()
|
|
}
|
|
|
|
async function deleteTask(task: ExportTaskItem) {
|
|
const ok = window.confirm(`确定删除导出任务 #${task.id} 吗?`)
|
|
if (!ok) {
|
|
return
|
|
}
|
|
try {
|
|
await fetchData(`/api/me/export/tasks/${task.id}`, {
|
|
method: 'DELETE',
|
|
notify: false,
|
|
})
|
|
toast.add({ title: `任务 #${task.id} 已删除`, color: 'success' })
|
|
await loadTasks()
|
|
} catch (e: unknown) {
|
|
toast.add({ title: getApiErrorMessage(e), color: 'error' })
|
|
}
|
|
}
|
|
|
|
onMounted(() => {
|
|
void loadTasks()
|
|
})
|
|
</script>
|
|
|
|
<template>
|
|
<UContainer class="max-w-6xl py-8 space-y-6">
|
|
<header class="space-y-1">
|
|
<h1 class="text-2xl font-semibold">
|
|
数据导出
|
|
</h1>
|
|
<p class="text-sm text-muted">
|
|
选择导出策略并发起任务,完成后可直接下载结果包。
|
|
</p>
|
|
</header>
|
|
|
|
<UCard>
|
|
<div class="flex flex-col gap-4 md:flex-row md:items-end md:justify-between">
|
|
<UFormField label="导出策略(maskPolicy)" class="w-full md:max-w-sm">
|
|
<USelect
|
|
v-model="maskPolicy"
|
|
class="w-full"
|
|
:items="maskPolicyItems"
|
|
value-key="value"
|
|
/>
|
|
</UFormField>
|
|
<div class="flex items-center gap-2">
|
|
<UButton
|
|
icon="i-lucide-file-output"
|
|
:loading="creating"
|
|
:disabled="creating"
|
|
@click="createTask"
|
|
>
|
|
发起导出
|
|
</UButton>
|
|
<UButton
|
|
variant="outline"
|
|
color="neutral"
|
|
icon="i-lucide-refresh-cw"
|
|
:loading="refreshing"
|
|
:disabled="creating || loading"
|
|
@click="loadTasks"
|
|
>
|
|
刷新
|
|
</UButton>
|
|
</div>
|
|
</div>
|
|
</UCard>
|
|
|
|
<section class="space-y-3" aria-labelledby="export-task-list-title">
|
|
<div class="flex items-center justify-between gap-3">
|
|
<h2 id="export-task-list-title" class="text-sm font-medium text-muted">
|
|
导出任务({{ tasks.length }})
|
|
</h2>
|
|
</div>
|
|
|
|
<div v-if="loading" class="text-sm text-muted">
|
|
加载中...
|
|
</div>
|
|
<UEmpty
|
|
v-else-if="!tasks.length"
|
|
title="暂无导出任务"
|
|
description="选择导出策略后,点击“发起导出”创建第一条任务。"
|
|
/>
|
|
<div v-else class="space-y-3">
|
|
<UCard v-for="task in tasks" :key="task.id">
|
|
<div class="flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
|
|
<div class="space-y-2 min-w-0">
|
|
<div class="flex flex-wrap items-center gap-2">
|
|
<span class="text-sm font-medium">任务 #{{ task.id }}</span>
|
|
<UBadge :color="statusToneMap[task.status]" variant="soft">
|
|
{{ statusLabelMap[task.status] }}
|
|
</UBadge>
|
|
<UBadge color="neutral" variant="outline">
|
|
{{ task.maskPolicy }}
|
|
</UBadge>
|
|
</div>
|
|
<p class="text-xs text-muted">
|
|
创建时间:{{ formatTime(task.createdAt) }}
|
|
</p>
|
|
<p class="text-xs text-muted">
|
|
更新时间:{{ formatTime(task.updatedAt) }}
|
|
</p>
|
|
<p class="text-xs text-muted">
|
|
文件大小:{{ formatBytes(task.totalBytes) }}
|
|
</p>
|
|
<p v-if="task.expiresAt" class="text-xs text-muted">
|
|
下载截止:{{ formatTime(task.expiresAt) }}
|
|
</p>
|
|
<p v-if="task.errorMessage" class="text-xs text-error">
|
|
失败原因:{{ task.errorMessage }}
|
|
</p>
|
|
</div>
|
|
<div class="flex items-center gap-2">
|
|
<UButton
|
|
v-if="task.status === 'succeeded'"
|
|
icon="i-lucide-download"
|
|
size="sm"
|
|
@click="openExportDownload(task.id)"
|
|
>
|
|
下载
|
|
</UButton>
|
|
<UButton
|
|
v-else-if="task.status === 'expired'"
|
|
icon="i-lucide-rotate-ccw"
|
|
variant="outline"
|
|
size="sm"
|
|
:loading="creating"
|
|
:disabled="creating"
|
|
@click="recreateTask(task)"
|
|
>
|
|
重新导出
|
|
</UButton>
|
|
<UButton
|
|
v-if="task.status !== 'running'"
|
|
icon="i-lucide-trash-2"
|
|
color="error"
|
|
variant="ghost"
|
|
size="sm"
|
|
:disabled="creating || refreshing"
|
|
@click="deleteTask(task)"
|
|
>
|
|
删除
|
|
</UButton>
|
|
</div>
|
|
</div>
|
|
</UCard>
|
|
</div>
|
|
</section>
|
|
</UContainer>
|
|
</template>
|
|
|