附件下载提示

master
lion 1 month ago
parent 30c2a3c2bb
commit 405a491a46

@ -9,6 +9,7 @@ import type {
AdminApplicationRow,
Paginated,
} from './types'
import { beginDownloadFeedback, notifyDownloadStarted } from '../../utils/downloadFeedback'
function unwrapPaginated<T>(raw: unknown): Paginated<T> {
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<void> {
const fileName = fallbackName?.trim() || '附件'
const feedback = beginDownloadFeedback(fileName, options?.size)
if (!feedback) {
return
}
try {
const res = await adminHttp.get<Blob>(
`/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()
}
}

@ -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('下载已开始,请查看浏览器下载进度')
}

@ -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()
}
}

@ -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<number | null>(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(() => {
</el-table-column>
<el-table-column label="操作" width="90" align="right">
<template #default="{ row }">
<el-button link type="primary" size="small" @click="downloadApplicationFile(row.id, row.original_name)">
下载
<el-button
link
type="primary"
size="small"
:loading="downloadingApplicationFileId === row.id"
:disabled="downloadingApplicationFileId != null"
@click="downloadApplicationFile(row.id, row.original_name, row.size)"
>
{{ downloadingApplicationFileId === row.id ? '下载中…' : '下载' }}
</el-button>
</template>
</el-table-column>

@ -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<Ref<string>>('manageCompetitionName', ref(''))
const loading = ref(false)
const detail = ref<AdminApplicationDetail | null>(null)
const downloadingFileId = ref<number | null>(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"
>
<a role="button" href="#" class="text-danger text-decoration-none" @click.prevent="downloadFile(planFile)">
{{ planFile.original_name }}
<a
role="button"
href="#"
class="text-danger text-decoration-none"
:class="{ 'pe-none opacity-75': downloadingFileId != null }"
@click.prevent="downloadFile(planFile)"
>
{{
downloadingFileId === planFile.id
? `下载中… ${planFile.original_name}`
: planFile.original_name
}}
</a>
</p>
<p v-else class="form-control-plaintext border rounded px-3 py-2 mb-0 bg-light small text-secondary">—</p>
@ -175,9 +196,16 @@ watch([competitionId, appId], () => void load(), { immediate: true })
<label class="form-label">其他佐证材料</label>
<ul class="list-unstyled mb-0 border rounded px-3 py-2 bg-light small">
<li v-for="f in supportingFiles" :key="f.id">
<a role="button" href="#" class="text-danger text-decoration-none" @click.prevent="downloadFile(f)">{{
f.original_name
}}</a>
<a
role="button"
href="#"
class="text-danger text-decoration-none"
:class="{ 'pe-none opacity-75': downloadingFileId != null }"
@click.prevent="downloadFile(f)"
>{{
downloadingFileId === f.id ? `下载中… ${f.original_name}` : f.original_name
}}</a
>
</li>
</ul>
</div>

@ -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<number | null>(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"
>
<a role="button" href="#" class="text-danger text-decoration-none" @click.prevent="downloadAttachment(planFile)">
{{ planFile.original_name }}
<a
role="button"
href="#"
class="text-danger text-decoration-none"
:class="{ 'pe-none opacity-75': downloadingFileId != null }"
@click.prevent="downloadAttachment(planFile)"
>
{{
downloadingFileId === planFile.id
? `下载中… ${planFile.original_name}`
: planFile.original_name
}}
</a>
</p>
<p v-else class="form-control-plaintext border rounded px-3 py-2 mb-0 bg-light small text-secondary">—</p>
@ -541,9 +568,16 @@ async function submitReviewScore() {
<label class="form-label">其他佐证材料</label>
<ul v-if="supportingFiles.length" class="list-unstyled mb-0 border rounded px-3 py-2 bg-light small">
<li v-for="f in supportingFiles" :key="f.id">
<a role="button" href="#" class="text-danger text-decoration-none" @click.prevent="downloadAttachment(f)">{{
f.original_name
}}</a>
<a
role="button"
href="#"
class="text-danger text-decoration-none"
:class="{ 'pe-none opacity-75': downloadingFileId != null }"
@click.prevent="downloadAttachment(f)"
>{{
downloadingFileId === f.id ? `下载中… ${f.original_name}` : f.original_name
}}</a
>
</li>
</ul>
<p v-else class="form-control-plaintext border rounded px-3 py-2 mb-0 bg-light small text-secondary">—</p>

Loading…
Cancel
Save