master
lion 3 months ago
parent ca7fb897e3
commit 77f281a469

@ -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<Error> {
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 || '导出失败')
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<string, string[]> }
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<void> {
})
}
function buildExportQueryParams(
params: AdminApplicationExportParams,
): Record<string, unknown> {
const { mode, application_ids, part, ...filters } = params
const query: Record<string, unknown> = { 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<AdminApplicationExportMeta> {
try {
const { data } = await adminHttp.get<AdminApplicationExportMeta>(
`/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<void> {
let res
try {
res = await adminHttp.get<Blob>(`/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<AdminApplicationExportMeta> {
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 }

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

@ -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<SignupChannelRow[]>([])
const channelsLoading = ref(false)
const applications = ref<AdminApplicationRow[]>([])
const selectedApplications = ref<AdminApplicationRow[]>([])
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(() => {
</el-select>
</div>
<div class="application-actions">
<el-button :loading="applicationsExporting" @click="exportApplications">
{{ applicationsExportProgress || '导出 ZIP' }}
</el-button>
<el-dropdown trigger="click" @command="exportApplications">
<el-button :loading="applicationsExporting">
{{ applicationsExportProgress || '导出' }}
<span class="export-dropdown-caret">▾</span>
</el-button>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item command="xlsx">仅导出表格</el-dropdown-item>
<el-dropdown-item command="selected">
导出表格 + 所选项目附件
<span v-if="selectedApplications.length" class="export-selected-count">
(已选 {{ selectedApplications.length }})
</span>
</el-dropdown-item>
<el-dropdown-item command="all">导出表格 + 全部项目附件</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
<el-button @click="resetApplicationFilters">重置</el-button>
<el-button type="primary" @click="refreshApplications(1)">查询</el-button>
</div>
</div>
<el-table :data="applications" stripe border empty-text="暂无报名记录" class="workspace-table">
<p v-if="selectedApplications.length" class="applications-selection-hint">
已选择 {{ selectedApplications.length }} 个项目,可使用「导出表格 + 所选项目附件」
</p>
<el-table
:data="applications"
row-key="id"
stripe
border
empty-text="暂无报名记录"
class="workspace-table"
@selection-change="onApplicationSelectionChange"
>
<el-table-column type="selection" width="48" fixed="left" reserve-selection />
<el-table-column prop="project_code" label="项目编号" width="120" show-overflow-tooltip />
<el-table-column label="状态" width="92">
<template #default="{ row }">
@ -2176,6 +2231,24 @@ onMounted(() => {
flex-wrap: wrap;
}
.export-dropdown-caret {
margin-left: 4px;
font-size: 12px;
opacity: 0.75;
}
.export-selected-count {
margin-left: 4px;
color: var(--el-color-primary);
font-size: 12px;
}
.applications-selection-hint {
margin: 0 0 8px;
color: var(--el-text-color-secondary);
font-size: 13px;
}
.table-pagination {
display: flex;
justify-content: flex-end;

Loading…
Cancel
Save