渠道导出

master
lion 3 months ago
parent 0d7e623878
commit cdaf33f8d7

@ -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<Error> {
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<string, string[]> }
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<string, string[]> }
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<void> {
return new Promise((resolve) => {
window.setTimeout(resolve, ms)
})
}
export async function getAdminApplicationExportMeta(
competitionId: number,
params: AdminApplicationListParams,
): Promise<AdminApplicationExportMeta> {
try {
const { data } = await adminHttp.get<AdminApplicationExportMeta>(
`/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<void> {
const res = await adminHttp.get<Blob>(`/competitions/${competitionId}/applications/export`, {
params,
responseType: 'blob',
})
let res
try {
res = await adminHttp.get<Blob>(`/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<AdminApplicationExportMeta> {
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
}

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

@ -101,6 +101,7 @@ const channelsLoading = ref(false)
const applications = ref<AdminApplicationRow[]>([])
const applicationsLoading = ref(false)
const applicationsExporting = ref(false)
const applicationsExportProgress = ref('')
const applicationDetailLoading = ref(false)
const applicationDetailVisible = ref(false)
const applicationDetail = ref<AdminApplicationDetail | null>(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(() => {
</el-select>
</div>
<div class="application-actions">
<el-button :loading="applicationsExporting" @click="exportApplications">导出 ZIP</el-button>
<el-button :loading="applicationsExporting" @click="exportApplications">
{{ applicationsExportProgress || '导出 ZIP' }}
</el-button>
<el-button @click="resetApplicationFilters">重置</el-button>
<el-button type="primary" @click="refreshApplications(1)">查询</el-button>
</div>

Loading…
Cancel
Save