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

<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>