From 77f281a469645d2a84f105b2f5cd69fe4ac072c0 Mon Sep 17 00:00:00 2001 From: lion <120344285@qq.com> Date: Wed, 17 Jun 2026 13:20:59 +0800 Subject: [PATCH] =?UTF-8?q?=E5=AF=BC=E5=87=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/api/admin/applications.ts | 55 +++++++++-- src/api/admin/types.ts | 10 ++ .../admin/competition/CompetitionFormView.vue | 93 +++++++++++++++++-- 3 files changed, 140 insertions(+), 18 deletions(-) diff --git a/src/api/admin/applications.ts b/src/api/admin/applications.ts index 50fc4a8..0ea610f 100644 --- a/src/api/admin/applications.ts +++ b/src/api/admin/applications.ts @@ -3,6 +3,8 @@ import { adminHttp } from './http' import type { AdminApplicationDetail, AdminApplicationExportMeta, + AdminApplicationExportMode, + AdminApplicationExportParams, AdminApplicationListParams, AdminApplicationRow, Paginated, @@ -87,14 +89,26 @@ async function readExportError(error: unknown): Promise { 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 || '导出失败') + return new Error( + payload.errors?.export?.[0] + || payload.errors?.application_ids?.[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 new Error( + payload.errors?.export?.[0] + || payload.errors?.application_ids?.[0] + || payload.errors?.part?.[0] + || payload.message + || '导出失败', + ) } } @@ -107,14 +121,28 @@ function sleep(ms: number): Promise { }) } +function buildExportQueryParams( + params: AdminApplicationExportParams, +): Record { + const { mode, application_ids, part, ...filters } = params + const query: Record = { mode, ...filters } + if (application_ids?.length) { + query.application_ids = application_ids + } + if (part != null) { + query.part = part + } + return query +} + export async function getAdminApplicationExportMeta( competitionId: number, - params: AdminApplicationListParams, + params: AdminApplicationExportParams, ): Promise { try { const { data } = await adminHttp.get( `/competitions/${competitionId}/applications/export/meta`, - { params }, + { params: buildExportQueryParams(params) }, ) if (!data || typeof data !== 'object') throw new Error('导出信息无效') return data @@ -125,13 +153,13 @@ export async function getAdminApplicationExportMeta( export async function downloadAdminApplicationExportPart( competitionId: number, - params: AdminApplicationListParams, + params: AdminApplicationExportParams, part: number, ): Promise { let res try { res = await adminHttp.get(`/competitions/${competitionId}/applications/export`, { - params: { ...params, part }, + params: buildExportQueryParams({ ...params, part }), responseType: 'blob', timeout: 0, }) @@ -139,20 +167,29 @@ export async function downloadAdminApplicationExportPart( throw await readExportError(error) } + const fallback = params.mode === 'xlsx' + ? '报名信息.xlsx' + : `报名信息导出_第${part}部分.zip` const filename = parseDownloadFilename( res.headers['content-disposition'] as string | undefined, - `报名信息导出_第${part}部分.zip`, + fallback, ) triggerBlobDownload(res.data, filename) } export async function exportAdminApplications( competitionId: number, - params: AdminApplicationListParams, + params: AdminApplicationExportParams, onProgress?: (current: number, total: number) => void, ): Promise { const meta = await getAdminApplicationExportMeta(competitionId, params) + if (params.mode === 'xlsx') { + onProgress?.(1, 1) + await downloadAdminApplicationExportPart(competitionId, params, 1) + return meta + } + for (let part = 1; part <= meta.part_count; part += 1) { onProgress?.(part, meta.part_count) await downloadAdminApplicationExportPart(competitionId, params, part) @@ -163,3 +200,5 @@ export async function exportAdminApplications( return meta } + +export type { AdminApplicationExportMode } diff --git a/src/api/admin/types.ts b/src/api/admin/types.ts index 1004f5b..39fa75b 100644 --- a/src/api/admin/types.ts +++ b/src/api/admin/types.ts @@ -104,10 +104,20 @@ export interface AdminApplicationListParams { signup_channel_id?: number } +export type AdminApplicationExportMode = 'xlsx' | 'selected' | 'all' + +export interface AdminApplicationExportParams extends AdminApplicationListParams { + mode: AdminApplicationExportMode + application_ids?: number[] + part?: number +} + export interface AdminApplicationExportMeta { total: number part_size: number part_count: number + mode: AdminApplicationExportMode + includes_attachments: boolean } export interface AdminApplicationRow { diff --git a/src/views/admin/competition/CompetitionFormView.vue b/src/views/admin/competition/CompetitionFormView.vue index 8402398..94a147b 100644 --- a/src/views/admin/competition/CompetitionFormView.vue +++ b/src/views/admin/competition/CompetitionFormView.vue @@ -25,6 +25,7 @@ import { exportAdminApplications, getAdminApplication, listAdminApplications, + type AdminApplicationExportMode, } from '../../../api/admin/applications' import type { AdminApplicationDetail, @@ -99,6 +100,7 @@ const tracksLoading = ref(false) const channels = ref([]) const channelsLoading = ref(false) const applications = ref([]) +const selectedApplications = ref([]) const applicationsLoading = ref(false) const applicationsExporting = ref(false) const applicationsExportProgress = ref('') @@ -732,29 +734,50 @@ function resetApplicationFilters() { void refreshApplications(1) } -async function exportApplications() { +async function exportApplications(mode: AdminApplicationExportMode) { const cid = competitionId.value if (!cid) return + if (mode === 'selected' && selectedApplications.value.length === 0) { + ElMessage.warning('请先勾选要导出附件的项目') + return + } applicationsExporting.value = true applicationsExportProgress.value = '准备导出…' try { const meta = await exportAdminApplications( cid, { + mode, 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, + application_ids: + mode === 'selected' ? selectedApplications.value.map((row) => row.id) : undefined, }, (current, total) => { + if (mode === 'xlsx') { + applicationsExportProgress.value = '正在下载表格…' + return + } applicationsExportProgress.value = `正在下载第 ${current}/${total} 个 ZIP…` }, ) - ElMessage.success( - meta.part_count > 1 - ? `已下载 ${meta.part_count} 个 ZIP(汇总表仅在第 1 个包,每个约 ${meta.part_size} 条附件)` - : '导出已开始下载', - ) + if (mode === 'xlsx') { + ElMessage.success(`已导出 ${meta.total} 条报名记录表格`) + } else if (mode === 'selected') { + ElMessage.success( + meta.part_count > 1 + ? `已下载 ${meta.part_count} 个 ZIP(含全部筛选表格与 ${selectedApplications.value.length} 个所选项目附件)` + : `已导出表格及 ${selectedApplications.value.length} 个所选项目附件`, + ) + } else if (meta.part_count > 1) { + ElMessage.success( + `已下载 ${meta.part_count} 个 ZIP(汇总表仅在第 1 个包,每个约 ${meta.part_size} 条项目附件)`, + ) + } else { + ElMessage.success('导出已开始下载') + } } catch (e) { ElMessage.error(e instanceof Error ? e.message : '导出失败') } finally { @@ -763,6 +786,10 @@ async function exportApplications() { } } +function onApplicationSelectionChange(rows: AdminApplicationRow[]) { + selectedApplications.value = rows +} + async function openApplicationDetail(row: AdminApplicationRow) { const cid = competitionId.value if (!cid) return @@ -1418,15 +1445,43 @@ onMounted(() => {
- - {{ applicationsExportProgress || '导出 ZIP' }} - + + + {{ applicationsExportProgress || '导出' }} + ▾ + + + 重置 查询
- +

+ 已选择 {{ selectedApplications.length }} 个项目,可使用「导出表格 + 所选项目附件」 +

+ + +