diff --git a/src/api/admin/applications.ts b/src/api/admin/applications.ts index 36f856e..89f73bd 100644 --- a/src/api/admin/applications.ts +++ b/src/api/admin/applications.ts @@ -9,6 +9,7 @@ import type { AdminApplicationRow, Paginated, } from './types' +import { beginDownloadFeedback, notifyDownloadStarted } from '../../utils/downloadFeedback' function unwrapPaginated(raw: unknown): Paginated { if (!raw || typeof raw !== 'object') throw new Error('列表响应格式无效') @@ -106,15 +107,25 @@ export async function downloadAdminApplicationFile( applicationId: number, fileId: number, fallbackName: string, + options?: { size?: number | null }, ): Promise { + const fileName = fallbackName?.trim() || '附件' + const feedback = beginDownloadFeedback(fileName, options?.size) + if (!feedback) { + return + } try { const res = await adminHttp.get( `/competitions/${competitionId}/applications/${applicationId}/files/${fileId}/download`, - { responseType: 'blob' }, + { responseType: 'blob', timeout: 0 }, ) - triggerBlobDownload(res.data, fallbackName || '附件') + feedback.setText(`正在保存「${fileName}」…`) + triggerBlobDownload(res.data, fileName) + notifyDownloadStarted() } catch (error) { throw await readBlobApiError(error, '下载附件失败') + } finally { + feedback.close() } } diff --git a/src/utils/downloadFeedback.ts b/src/utils/downloadFeedback.ts new file mode 100644 index 0000000..f0ca5da --- /dev/null +++ b/src/utils/downloadFeedback.ts @@ -0,0 +1,65 @@ +import { ElMessage, type MessageHandler } from 'element-plus' + +let downloadBusy = false + +export function isDownloadBusy(): boolean { + return downloadBusy +} + +export function formatDownloadSizeHint(size?: number | null): string { + if (size == null || !Number.isFinite(size) || size <= 0) return '' + if (size < 1024 * 1024) return `约 ${Math.max(1, Math.round(size / 1024))} KB` + return `约 ${(size / (1024 * 1024)).toFixed(1)} MB` +} + +export type DownloadFeedbackHandle = { + setText: (text: string) => void + close: () => void +} + +/** + * 下载过程中的轻量提示(非全屏);若已有下载进行中则提示并返回 null。 + */ +export function beginDownloadFeedback( + fileName: string, + size?: number | null, + actionLabel = '下载', +): DownloadFeedbackHandle | null { + if (downloadBusy) { + ElMessage.warning(`附件正在${actionLabel}中,请勿重复点击`) + return null + } + downloadBusy = true + const name = fileName.trim() || '附件' + const sizeHint = formatDownloadSizeHint(size) + const text = sizeHint + ? `正在准备${actionLabel}「${name}」(${sizeHint}),文件较大时请耐心等待…` + : `正在准备${actionLabel}「${name}」,请稍候…` + + let handler: MessageHandler = ElMessage({ + type: 'info', + message: text, + duration: 0, + showClose: true, + }) + + return { + setText: (next: string) => { + handler.close() + handler = ElMessage({ + type: 'info', + message: next, + duration: 0, + showClose: true, + }) + }, + close: () => { + handler.close() + downloadBusy = false + }, + } +} + +export function notifyDownloadStarted(): void { + ElMessage.success('下载已开始,请查看浏览器下载进度') +} diff --git a/src/views/ApplyFormView.vue b/src/views/ApplyFormView.vue index a574dd5..b140d82 100644 --- a/src/views/ApplyFormView.vue +++ b/src/views/ApplyFormView.vue @@ -23,6 +23,7 @@ import { type BrandingForm, } from '../utils/competitionBranding' import { entryConsultPhones, techConsultPhone } from '../config/participantContact' +import { beginDownloadFeedback } from '../utils/downloadFeedback' import { CONTEST_DEFAULT_FILE_EXTENSIONS, normalizeSignupSchema, @@ -956,6 +957,9 @@ async function openFilePreview(item: FileItem) { window.open(url, '_blank', 'noopener,noreferrer') return } + const fileName = item.original_name?.trim() || '附件' + const feedback = beginDownloadFeedback(fileName, item.size, '打开') + if (!feedback) return try { const r = await fetch(url, { credentials: 'omit', @@ -974,12 +978,15 @@ async function openFilePreview(item: FileItem) { await showNotice(message, '提示', 'warning') return } + feedback.setText(`正在打开「${fileName}」…`) const blob = await r.blob() const blobUrl = URL.createObjectURL(blob) item.localBlobUrl = blobUrl window.open(blobUrl, '_blank', 'noopener,noreferrer') } catch { await showNotice('网络错误,无法打开附件', '提示', 'warning') + } finally { + feedback.close() } } diff --git a/src/views/admin/competition/CompetitionFormView.vue b/src/views/admin/competition/CompetitionFormView.vue index cbb52eb..42275ac 100644 --- a/src/views/admin/competition/CompetitionFormView.vue +++ b/src/views/admin/competition/CompetitionFormView.vue @@ -2,6 +2,7 @@ import { computed, onMounted, ref, watch } from 'vue' import { useRoute, useRouter } from 'vue-router' import { ElMessage, ElMessageBox } from 'element-plus' +import { isDownloadBusy } from '../../../utils/downloadFeedback' import { storeToRefs } from 'pinia' import { createCompetition, @@ -1068,14 +1069,27 @@ async function openApplicationDetail(row: AdminApplicationRow) { } } -async function downloadApplicationFile(fileId: number, fileName: string | null | undefined) { +const downloadingApplicationFileId = ref(null) + +async function downloadApplicationFile( + fileId: number, + fileName: string | null | undefined, + size?: number | null, +) { const cid = competitionId.value const appId = applicationDetail.value?.id if (!cid || !appId) return + if (isDownloadBusy() || downloadingApplicationFileId.value != null) { + ElMessage.warning('附件正在下载中,请勿重复点击') + return + } + downloadingApplicationFileId.value = fileId try { - await downloadAdminApplicationFile(cid, appId, fileId, fileName || '附件') + await downloadAdminApplicationFile(cid, appId, fileId, fileName || '附件', { size }) } catch (e) { ElMessage.error(e instanceof Error ? e.message : '下载附件失败') + } finally { + downloadingApplicationFileId.value = null } } @@ -2248,8 +2262,15 @@ onMounted(() => { diff --git a/src/views/manage/ManageProjectDetailView.vue b/src/views/manage/ManageProjectDetailView.vue index 73dfcda..e574ef7 100644 --- a/src/views/manage/ManageProjectDetailView.vue +++ b/src/views/manage/ManageProjectDetailView.vue @@ -5,6 +5,7 @@ import { ElMessage } from 'element-plus' import ApplicationSignupDetailFields from '../../components/portal/ApplicationSignupDetailFields.vue' import { downloadAdminApplicationFile, getAdminApplication } from '../../api/admin/applications' import type { AdminApplicationDetail, AdminApplicationFileRow } from '../../api/admin/types' +import { isDownloadBusy } from '../../utils/downloadFeedback' import { listPageStateKey, listPageStateToQuery, @@ -20,6 +21,7 @@ const competitionName = inject>('manageCompetitionName', ref('')) const loading = ref(false) const detail = ref(null) +const downloadingFileId = ref(null) const appId = computed(() => Number(route.params.id)) @@ -97,10 +99,19 @@ async function downloadFile(file: AdminApplicationFileRow) { const cid = competitionId.value const app = detail.value if (!cid || !app) return + if (isDownloadBusy()) { + ElMessage.warning('附件正在下载中,请勿重复点击') + return + } + downloadingFileId.value = file.id try { - await downloadAdminApplicationFile(cid, app.id, file.id, file.original_name || '附件') + await downloadAdminApplicationFile(cid, app.id, file.id, file.original_name || '附件', { + size: file.size, + }) } catch (e) { ElMessage.error(e instanceof Error ? e.message : '下载附件失败') + } finally { + downloadingFileId.value = null } } @@ -165,8 +176,18 @@ watch([competitionId, appId], () => void load(), { immediate: true }) v-if="planFile" class="form-control-plaintext border rounded px-3 py-2 mb-0 bg-light small text-secondary" > - - {{ planFile.original_name }} + + {{ + downloadingFileId === planFile.id + ? `下载中… ${planFile.original_name}` + : planFile.original_name + }}

—

@@ -175,9 +196,16 @@ watch([competitionId, appId], () => void load(), { immediate: true }) diff --git a/src/views/reviewer/ReviewerApplicationDetailView.vue b/src/views/reviewer/ReviewerApplicationDetailView.vue index cbfed28..138ef1b 100644 --- a/src/views/reviewer/ReviewerApplicationDetailView.vue +++ b/src/views/reviewer/ReviewerApplicationDetailView.vue @@ -8,6 +8,7 @@ import { reviewApplicationSubmitScoreUrl, } from '../../config/api' import { ElMessage } from 'element-plus' +import { beginDownloadFeedback, notifyDownloadStarted } from '../../utils/downloadFeedback' import { Modal } from 'bootstrap' import ApplicationSignupDetailFields from '../../components/portal/ApplicationSignupDetailFields.vue' import { @@ -329,7 +330,13 @@ function goBack() { void router.push({ name: 'reviewer-projects', params: { slug: s }, query }) } +const downloadingFileId = ref(null) + async function downloadAttachment(f: FileItem) { + if (downloadingFileId.value != null) { + ElMessage.warning('附件正在下载中,请勿重复点击') + return + } const id = applicationId.value const s = slug.value const { token } = readReviewerSession() @@ -337,6 +344,11 @@ async function downloadAttachment(f: FileItem) { ElMessage.warning('未登录或参数无效') return } + const fileName = f.original_name?.trim() || '附件' + const feedback = beginDownloadFeedback(fileName, f.size) + if (!feedback) return + + downloadingFileId.value = f.id const url = reviewApplicationFileDownloadUrl(parseInt(id, 10), f.id, s) try { const r = await fetch(url, { @@ -355,18 +367,23 @@ async function downloadAttachment(f: FileItem) { ElMessage.error(message) return } + feedback.setText(`正在保存「${fileName}」…`) const blob = await r.blob() const objectUrl = URL.createObjectURL(blob) const a = document.createElement('a') a.href = objectUrl - a.download = f.original_name?.trim() ? f.original_name : 'download' + a.download = fileName === '附件' ? 'download' : fileName a.rel = 'noopener' document.body.appendChild(a) a.click() a.remove() URL.revokeObjectURL(objectUrl) + notifyDownloadStarted() } catch { ElMessage.error('网络错误,无法下载') + } finally { + feedback.close() + downloadingFileId.value = null } } @@ -531,8 +548,18 @@ async function submitReviewScore() { v-if="planFile" class="form-control-plaintext border rounded px-3 py-2 mb-0 bg-light small text-secondary" > - - {{ planFile.original_name }} + + {{ + downloadingFileId === planFile.id + ? `下载中… ${planFile.original_name}` + : planFile.original_name + }}

—

@@ -541,9 +568,16 @@ async function submitReviewScore() {

—