master
lion 4 months ago
parent ca7fb897e3
commit 77f281a469

@ -3,6 +3,8 @@ import { adminHttp } from './http'
import type { import type {
AdminApplicationDetail, AdminApplicationDetail,
AdminApplicationExportMeta, AdminApplicationExportMeta,
AdminApplicationExportMode,
AdminApplicationExportParams,
AdminApplicationListParams, AdminApplicationListParams,
AdminApplicationRow, AdminApplicationRow,
Paginated, Paginated,
@ -87,14 +89,26 @@ async function readExportError(error: unknown): Promise<Error> {
const text = await data.text() const text = await data.text()
try { try {
const payload = JSON.parse(text) as { message?: string; errors?: Record<string, string[]> } 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 { } catch {
// ignore non-json blob errors // ignore non-json blob errors
} }
} }
if (data && typeof data === 'object') { if (data && typeof data === 'object') {
const payload = data as { message?: string; errors?: Record<string, string[]> } 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( export async function getAdminApplicationExportMeta(
competitionId: number, competitionId: number,
params: AdminApplicationListParams, params: AdminApplicationExportParams,
): Promise<AdminApplicationExportMeta> { ): Promise<AdminApplicationExportMeta> {
try { try {
const { data } = await adminHttp.get<AdminApplicationExportMeta>( const { data } = await adminHttp.get<AdminApplicationExportMeta>(
`/competitions/${competitionId}/applications/export/meta`, `/competitions/${competitionId}/applications/export/meta`,
{ params }, { params: buildExportQueryParams(params) },
) )
if (!data || typeof data !== 'object') throw new Error('导出信息无效') if (!data || typeof data !== 'object') throw new Error('导出信息无效')
return data return data
@ -125,13 +153,13 @@ export async function getAdminApplicationExportMeta(
export async function downloadAdminApplicationExportPart( export async function downloadAdminApplicationExportPart(
competitionId: number, competitionId: number,
params: AdminApplicationListParams, params: AdminApplicationExportParams,
part: number, part: number,
): Promise<void> { ): Promise<void> {
let res let res
try { try {
res = await adminHttp.get<Blob>(`/competitions/${competitionId}/applications/export`, { res = await adminHttp.get<Blob>(`/competitions/${competitionId}/applications/export`, {
params: { ...params, part }, params: buildExportQueryParams({ ...params, part }),
responseType: 'blob', responseType: 'blob',
timeout: 0, timeout: 0,
}) })
@ -139,20 +167,29 @@ export async function downloadAdminApplicationExportPart(
throw await readExportError(error) throw await readExportError(error)
} }
const fallback = params.mode === 'xlsx'
? '报名信息.xlsx'
: `报名信息导出_第${part}部分.zip`
const filename = parseDownloadFilename( const filename = parseDownloadFilename(
res.headers['content-disposition'] as string | undefined, res.headers['content-disposition'] as string | undefined,
`报名信息导出_第${part}部分.zip`, fallback,
) )
triggerBlobDownload(res.data, filename) triggerBlobDownload(res.data, filename)
} }
export async function exportAdminApplications( export async function exportAdminApplications(
competitionId: number, competitionId: number,
params: AdminApplicationListParams, params: AdminApplicationExportParams,
onProgress?: (current: number, total: number) => void, onProgress?: (current: number, total: number) => void,
): Promise<AdminApplicationExportMeta> { ): Promise<AdminApplicationExportMeta> {
const meta = await getAdminApplicationExportMeta(competitionId, params) 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) { for (let part = 1; part <= meta.part_count; part += 1) {
onProgress?.(part, meta.part_count) onProgress?.(part, meta.part_count)
await downloadAdminApplicationExportPart(competitionId, params, part) await downloadAdminApplicationExportPart(competitionId, params, part)
@ -163,3 +200,5 @@ export async function exportAdminApplications(
return meta return meta
} }
export type { AdminApplicationExportMode }

@ -104,10 +104,20 @@ export interface AdminApplicationListParams {
signup_channel_id?: number 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 { export interface AdminApplicationExportMeta {
total: number total: number
part_size: number part_size: number
part_count: number part_count: number
mode: AdminApplicationExportMode
includes_attachments: boolean
} }
export interface AdminApplicationRow { export interface AdminApplicationRow {

@ -25,6 +25,7 @@ import {
exportAdminApplications, exportAdminApplications,
getAdminApplication, getAdminApplication,
listAdminApplications, listAdminApplications,
type AdminApplicationExportMode,
} from '../../../api/admin/applications' } from '../../../api/admin/applications'
import type { import type {
AdminApplicationDetail, AdminApplicationDetail,
@ -99,6 +100,7 @@ const tracksLoading = ref(false)
const channels = ref<SignupChannelRow[]>([]) const channels = ref<SignupChannelRow[]>([])
const channelsLoading = ref(false) const channelsLoading = ref(false)
const applications = ref<AdminApplicationRow[]>([]) const applications = ref<AdminApplicationRow[]>([])
const selectedApplications = ref<AdminApplicationRow[]>([])
const applicationsLoading = ref(false) const applicationsLoading = ref(false)
const applicationsExporting = ref(false) const applicationsExporting = ref(false)
const applicationsExportProgress = ref('') const applicationsExportProgress = ref('')
@ -732,29 +734,50 @@ function resetApplicationFilters() {
void refreshApplications(1) void refreshApplications(1)
} }
async function exportApplications() { async function exportApplications(mode: AdminApplicationExportMode) {
const cid = competitionId.value const cid = competitionId.value
if (!cid) return if (!cid) return
if (mode === 'selected' && selectedApplications.value.length === 0) {
ElMessage.warning('请先勾选要导出附件的项目')
return
}
applicationsExporting.value = true applicationsExporting.value = true
applicationsExportProgress.value = '准备导出…' applicationsExportProgress.value = '准备导出…'
try { try {
const meta = await exportAdminApplications( const meta = await exportAdminApplications(
cid, cid,
{ {
mode,
keyword: applicationFilters.value.keyword.trim() || undefined, keyword: applicationFilters.value.keyword.trim() || undefined,
status: applicationFilters.value.status || undefined, status: applicationFilters.value.status || undefined,
track: applicationFilters.value.track || undefined, track: applicationFilters.value.track || undefined,
signup_channel_id: applicationFilters.value.signup_channel_id || undefined, signup_channel_id: applicationFilters.value.signup_channel_id || undefined,
application_ids:
mode === 'selected' ? selectedApplications.value.map((row) => row.id) : undefined,
}, },
(current, total) => { (current, total) => {
if (mode === 'xlsx') {
applicationsExportProgress.value = '正在下载表格…'
return
}
applicationsExportProgress.value = `正在下载第 ${current}/${total} 个 ZIP…` applicationsExportProgress.value = `正在下载第 ${current}/${total} 个 ZIP…`
}, },
) )
ElMessage.success( if (mode === 'xlsx') {
meta.part_count > 1 ElMessage.success(`已导出 ${meta.total} 条报名记录表格`)
? `已下载 ${meta.part_count} 个 ZIP(汇总表仅在第 1 个包,每个约 ${meta.part_size} 条附件)` } 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) { } catch (e) {
ElMessage.error(e instanceof Error ? e.message : '导出失败') ElMessage.error(e instanceof Error ? e.message : '导出失败')
} finally { } finally {
@ -763,6 +786,10 @@ async function exportApplications() {
} }
} }
function onApplicationSelectionChange(rows: AdminApplicationRow[]) {
selectedApplications.value = rows
}
async function openApplicationDetail(row: AdminApplicationRow) { async function openApplicationDetail(row: AdminApplicationRow) {
const cid = competitionId.value const cid = competitionId.value
if (!cid) return if (!cid) return
@ -1418,15 +1445,43 @@ onMounted(() => {
</el-select> </el-select>
</div> </div>
<div class="application-actions"> <div class="application-actions">
<el-button :loading="applicationsExporting" @click="exportApplications"> <el-dropdown trigger="click" @command="exportApplications">
{{ applicationsExportProgress || '导出 ZIP' }} <el-button :loading="applicationsExporting">
</el-button> {{ 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 @click="resetApplicationFilters">重置</el-button>
<el-button type="primary" @click="refreshApplications(1)">查询</el-button> <el-button type="primary" @click="refreshApplications(1)">查询</el-button>
</div> </div>
</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 prop="project_code" label="项目编号" width="120" show-overflow-tooltip />
<el-table-column label="状态" width="92"> <el-table-column label="状态" width="92">
<template #default="{ row }"> <template #default="{ row }">
@ -2176,6 +2231,24 @@ onMounted(() => {
flex-wrap: wrap; 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 { .table-pagination {
display: flex; display: flex;
justify-content: flex-end; justify-content: flex-end;

Loading…
Cancel
Save