diff --git a/src/api/admin/applications.ts b/src/api/admin/applications.ts index 5594030..50fc4a8 100644 --- a/src/api/admin/applications.ts +++ b/src/api/admin/applications.ts @@ -1,6 +1,8 @@ +import axios from 'axios' import { adminHttp } from './http' import type { AdminApplicationDetail, + AdminApplicationExportMeta, AdminApplicationListParams, AdminApplicationRow, Paginated, @@ -53,43 +55,111 @@ export async function downloadAdminApplicationFile( `/competitions/${competitionId}/applications/${applicationId}/files/${fileId}/download`, { responseType: 'blob' }, ) - const blobUrl = URL.createObjectURL(res.data) + triggerBlobDownload(res.data, fallbackName || '附件') +} + +function triggerBlobDownload(blob: Blob, filename: string): void { + const blobUrl = URL.createObjectURL(blob) const a = document.createElement('a') a.href = blobUrl - a.download = fallbackName || '附件' + a.download = filename document.body.appendChild(a) a.click() a.remove() URL.revokeObjectURL(blobUrl) } -export async function exportAdminApplications( +function parseDownloadFilename(disposition: string | undefined, fallback: string): string { + if (!disposition) return fallback + const utf8Match = /filename\*=UTF-8''([^;]+)/i.exec(disposition) + if (utf8Match?.[1]) { + return decodeURIComponent(utf8Match[1]) + } + const plainMatch = /filename="?([^";]+)"?/i.exec(disposition) + if (plainMatch?.[1]) return plainMatch[1] + return fallback +} + +async function readExportError(error: unknown): Promise { + if (axios.isAxiosError(error)) { + const data = error.response?.data + if (data instanceof Blob) { + const text = await data.text() + try { + const payload = JSON.parse(text) as { message?: string; errors?: Record } + return new Error(payload.errors?.export?.[0] || payload.errors?.part?.[0] || payload.message || '导出失败') + } catch { + // ignore non-json blob errors + } + } + if (data && typeof data === 'object') { + const payload = data as { message?: string; errors?: Record } + return new Error(payload.errors?.export?.[0] || payload.errors?.part?.[0] || payload.message || '导出失败') + } + } + + return error instanceof Error ? error : new Error('导出失败') +} + +function sleep(ms: number): Promise { + return new Promise((resolve) => { + window.setTimeout(resolve, ms) + }) +} + +export async function getAdminApplicationExportMeta( competitionId: number, params: AdminApplicationListParams, +): Promise { + try { + const { data } = await adminHttp.get( + `/competitions/${competitionId}/applications/export/meta`, + { params }, + ) + if (!data || typeof data !== 'object') throw new Error('导出信息无效') + return data + } catch (error) { + throw await readExportError(error) + } +} + +export async function downloadAdminApplicationExportPart( + competitionId: number, + params: AdminApplicationListParams, + part: number, ): Promise { - const res = await adminHttp.get(`/competitions/${competitionId}/applications/export`, { - params, - responseType: 'blob', - }) + let res + try { + res = await adminHttp.get(`/competitions/${competitionId}/applications/export`, { + params: { ...params, part }, + responseType: 'blob', + timeout: 0, + }) + } catch (error) { + throw await readExportError(error) + } + + const filename = parseDownloadFilename( + res.headers['content-disposition'] as string | undefined, + `报名信息导出_第${part}部分.zip`, + ) + triggerBlobDownload(res.data, filename) +} + +export async function exportAdminApplications( + competitionId: number, + params: AdminApplicationListParams, + onProgress?: (current: number, total: number) => void, +): Promise { + const meta = await getAdminApplicationExportMeta(competitionId, params) - const disposition = res.headers['content-disposition'] as string | undefined - let filename = '报名信息导出.zip' - if (disposition) { - const utf8Match = /filename\*=UTF-8''([^;]+)/i.exec(disposition) - if (utf8Match?.[1]) { - filename = decodeURIComponent(utf8Match[1]) - } else { - const plainMatch = /filename="?([^";]+)"?/i.exec(disposition) - if (plainMatch?.[1]) filename = plainMatch[1] + for (let part = 1; part <= meta.part_count; part += 1) { + onProgress?.(part, meta.part_count) + await downloadAdminApplicationExportPart(competitionId, params, part) + if (part < meta.part_count) { + await sleep(500) } } - const blobUrl = URL.createObjectURL(res.data) - const a = document.createElement('a') - a.href = blobUrl - a.download = filename - document.body.appendChild(a) - a.click() - a.remove() - URL.revokeObjectURL(blobUrl) + return meta } diff --git a/src/api/admin/types.ts b/src/api/admin/types.ts index bf8d2ed..1004f5b 100644 --- a/src/api/admin/types.ts +++ b/src/api/admin/types.ts @@ -104,6 +104,12 @@ export interface AdminApplicationListParams { signup_channel_id?: number } +export interface AdminApplicationExportMeta { + total: number + part_size: number + part_count: number +} + export interface AdminApplicationRow { id: number project_code: string diff --git a/src/views/admin/competition/CompetitionFormView.vue b/src/views/admin/competition/CompetitionFormView.vue index 7953d02..8402398 100644 --- a/src/views/admin/competition/CompetitionFormView.vue +++ b/src/views/admin/competition/CompetitionFormView.vue @@ -101,6 +101,7 @@ const channelsLoading = ref(false) const applications = ref([]) const applicationsLoading = ref(false) const applicationsExporting = ref(false) +const applicationsExportProgress = ref('') const applicationDetailLoading = ref(false) const applicationDetailVisible = ref(false) const applicationDetail = ref(null) @@ -735,18 +736,30 @@ async function exportApplications() { const cid = competitionId.value if (!cid) return applicationsExporting.value = true + applicationsExportProgress.value = '准备导出…' try { - await exportAdminApplications(cid, { - keyword: applicationFilters.value.keyword.trim() || undefined, - status: applicationFilters.value.status || undefined, - track: applicationFilters.value.track || undefined, - signup_channel_id: applicationFilters.value.signup_channel_id || undefined, - }) - ElMessage.success('导出已开始下载') + const meta = await exportAdminApplications( + cid, + { + keyword: applicationFilters.value.keyword.trim() || undefined, + status: applicationFilters.value.status || undefined, + track: applicationFilters.value.track || undefined, + signup_channel_id: applicationFilters.value.signup_channel_id || undefined, + }, + (current, total) => { + applicationsExportProgress.value = `正在下载第 ${current}/${total} 个 ZIP…` + }, + ) + ElMessage.success( + meta.part_count > 1 + ? `已下载 ${meta.part_count} 个 ZIP(汇总表仅在第 1 个包,每个约 ${meta.part_size} 条附件)` + : '导出已开始下载', + ) } catch (e) { ElMessage.error(e instanceof Error ? e.message : '导出失败') } finally { applicationsExporting.value = false + applicationsExportProgress.value = '' } } @@ -1405,7 +1418,9 @@ onMounted(() => {
- 导出 ZIP + + {{ applicationsExportProgress || '导出 ZIP' }} + 重置 查询