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(() => {
-
- 下载
+
+ {{ downloadingApplicationFileId === row.id ? '下载中…' : '下载' }}
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() {
—