From 049545613eee68221ad085db767b507bd21b3660 Mon Sep 17 00:00:00 2001 From: lion <120344285@qq.com> Date: Wed, 5 Aug 2026 19:18:54 +0800 Subject: [PATCH] =?UTF-8?q?=E9=99=84=E4=BB=B6=E4=B8=8A=E4=BC=A0=E4=B8=B4?= =?UTF-8?q?=E6=97=B6=E5=BC=80=E5=90=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/api/admin/applications.ts | 28 ++++--- src/api/admin/types.ts | 6 ++ src/views/ApplyFormView.vue | 50 +++++++++--- .../admin/competition/CompetitionFormView.vue | 81 +++++++++++++++++++ src/views/manage/ManageProjectDetailView.vue | 4 +- .../ReviewerApplicationDetailView.vue | 13 ++- 6 files changed, 160 insertions(+), 22 deletions(-) diff --git a/src/api/admin/applications.ts b/src/api/admin/applications.ts index 0ea610f..2f5da78 100644 --- a/src/api/admin/applications.ts +++ b/src/api/admin/applications.ts @@ -53,11 +53,15 @@ export async function downloadAdminApplicationFile( fileId: number, fallbackName: string, ): Promise { - const res = await adminHttp.get( - `/competitions/${competitionId}/applications/${applicationId}/files/${fileId}/download`, - { responseType: 'blob' }, - ) - triggerBlobDownload(res.data, fallbackName || '附件') + try { + const res = await adminHttp.get( + `/competitions/${competitionId}/applications/${applicationId}/files/${fileId}/download`, + { responseType: 'blob' }, + ) + triggerBlobDownload(res.data, fallbackName || '附件') + } catch (error) { + throw await readBlobApiError(error, '下载附件失败') + } } function triggerBlobDownload(blob: Blob, filename: string): void { @@ -82,7 +86,7 @@ function parseDownloadFilename(disposition: string | undefined, fallback: string return fallback } -async function readExportError(error: unknown): Promise { +async function readBlobApiError(error: unknown, fallback: string): Promise { if (axios.isAxiosError(error)) { const data = error.response?.data if (data instanceof Blob) { @@ -93,8 +97,9 @@ async function readExportError(error: unknown): Promise { payload.errors?.export?.[0] || payload.errors?.application_ids?.[0] || payload.errors?.part?.[0] + || payload.errors?.file?.[0] || payload.message - || '导出失败', + || fallback, ) } catch { // ignore non-json blob errors @@ -106,13 +111,18 @@ async function readExportError(error: unknown): Promise { payload.errors?.export?.[0] || payload.errors?.application_ids?.[0] || payload.errors?.part?.[0] + || payload.errors?.file?.[0] || payload.message - || '导出失败', + || fallback, ) } } - return error instanceof Error ? error : new Error('导出失败') + return error instanceof Error ? error : new Error(fallback) +} + +async function readExportError(error: unknown): Promise { + return readBlobApiError(error, '导出失败') } function sleep(ms: number): Promise { diff --git a/src/api/admin/types.ts b/src/api/admin/types.ts index d46a672..0d6d4be 100644 --- a/src/api/admin/types.ts +++ b/src/api/admin/types.ts @@ -42,6 +42,12 @@ export interface CompetitionReviewPortalSettings { message: string } +/** 赛事 settings.post_close_file_edit:截止后指定登录手机号可补传附件 */ +export interface CompetitionPostCloseFileEditSettings { + enabled: boolean + user_mobiles: string[] +} + export interface CompetitionPayload { slug: string name: string diff --git a/src/views/ApplyFormView.vue b/src/views/ApplyFormView.vue index 26fe910..a574dd5 100644 --- a/src/views/ApplyFormView.vue +++ b/src/views/ApplyFormView.vue @@ -231,11 +231,17 @@ const applicationStatus = ref<'draft' | 'submitted'>('draft') const submittedAt = ref('') /** 与后端 participant_may_edit 对齐:有评审记录后为 false,表单整表禁用 */ const participantMayEdit = ref(true) +/** 与后端 participant_may_edit_files 对齐:截止后白名单可仅改附件 */ +const participantMayEditFiles = ref(true) const isSubmitted = computed(() => applicationStatus.value === 'submitted') const submittedAtText = computed(() => formatSubmittedAt(submittedAt.value)) const applicationStatusTitle = computed(() => { if (isSubmitted.value && participantMayEdit.value) return '报名已提交,可继续修改' + if (isSubmitted.value && participantMayEditFiles.value && !participantMayEdit.value) { + return '报名已提交,仅可补传附件' + } if (isSubmitted.value) return '报名已提交,已锁定' + if (!participantMayEdit.value && participantMayEditFiles.value) return '报名未提交,仅可补传附件' if (!participantMayEdit.value) return '报名未提交,当前不可编辑' return '报名信息未提交' }) @@ -244,6 +250,10 @@ const applicationStatusBody = computed(() => { const time = submittedAtText.value ? `上次提交时间:${submittedAtText.value}。` : '' return `${time}在报名截止且没有任一评委提交评分前,你仍可修改资料、增删附件,并点击“更新报名”覆盖提交。评审开始后报名会自动锁定,请保持联系方式畅通。` } + if (participantMayEditFiles.value && !participantMayEdit.value) { + const time = submittedAtText.value ? `提交时间:${submittedAtText.value}。` : '' + return `${time}报名已截止,组委会已开放你补传附件:可上传或删除附件,上传成功即生效,无需再次提交。其他报名信息不可修改;若已有评委评审则仍会锁定。` + } if (isSubmitted.value) { const time = submittedAtText.value ? `提交时间:${submittedAtText.value}。` : '' return `${time}报名已因截止或评审记录产生而锁定,不能再修改资料、增删附件或重复提交。后续请等待赛事组委会通知;如需更正请联系组委会。` @@ -343,6 +353,8 @@ const supportingFilesFeedback = ref('') const wasValidated = ref(false) const formDisabled = computed(() => !participantMayEdit.value) +/** 附件上传/删除:截止后白名单可单独放开 */ +const fileControlsDisabled = computed(() => !participantMayEditFiles.value) const applyFormEl = ref(null) /** 在 v-for 中绑定时 Vue 可能将 ref 设为元素数组 */ @@ -945,20 +957,35 @@ async function openFilePreview(item: FileItem) { return } try { - const r = await fetch(url, { credentials: 'omit' }) - if (!r.ok) throw new Error('fetch failed') + const r = await fetch(url, { + credentials: 'omit', + headers: { Accept: 'application/json,*/*' }, + }) + if (!r.ok) { + let message = '附件文件不存在或已丢失' + try { + const payload = (await r.json()) as { message?: string } + if (typeof payload.message === 'string' && payload.message.trim()) { + message = payload.message.trim() + } + } catch { + // ignore non-json error bodies + } + await showNotice(message, '提示', 'warning') + return + } const blob = await r.blob() const blobUrl = URL.createObjectURL(blob) item.localBlobUrl = blobUrl window.open(blobUrl, '_blank', 'noopener,noreferrer') } catch { - window.open(url, '_blank', 'noopener,noreferrer') + await showNotice('网络错误,无法打开附件', '提示', 'warning') } } async function removeFile(id: string | number, target: 'plan' | 'supporting') { const key = String(id) - if (deletingFileIds.value.includes(key)) return + if (fileControlsDisabled.value || deletingFileIds.value.includes(key)) return let items = target === 'plan' ? [...planFileItems.value] : [...supportingFileItems.value] const item = items.find((e) => String(e.id) === key) @@ -1014,7 +1041,7 @@ async function removeFile(id: string | number, target: 'plan' | 'supporting') { } function addFiles(fileList: FileList | null, target: 'plan' | 'supporting') { - if (!fileList?.length) return + if (!fileList?.length || fileControlsDisabled.value) return const field = fileFieldSchema(target) const maxC = effectiveMaxFileCountForField(field) const items = target === 'plan' ? [...planFileItems.value] : [...supportingFileItems.value] @@ -1149,6 +1176,7 @@ async function loadPublicCompetition() { function applyServerPayload(d: { status?: string participant_may_edit?: boolean + participant_may_edit_files?: boolean player_name?: string school?: string degree?: string @@ -1172,6 +1200,10 @@ function applyServerPayload(d: { applicationStatus.value = (d.status as 'draft' | 'submitted') || 'draft' submittedAt.value = typeof d.submitted_at === 'string' ? d.submitted_at : '' participantMayEdit.value = d.participant_may_edit !== false + participantMayEditFiles.value = + typeof d.participant_may_edit_files === 'boolean' + ? d.participant_may_edit_files + : d.participant_may_edit !== false for (const f of schemaFields.value) { if (f.type === 'file') continue if (f.key === 'commitment_accepted' || f.key === 'promise_signature') continue @@ -1573,7 +1605,7 @@ onMounted(() => { :class="{ 'is-invalid': wasValidated && !!planFileFeedback }" :accept="fileInputAcceptAttr(field)" multiple - :disabled="formDisabled" + :disabled="fileControlsDisabled" @change="addFiles(($event.target as HTMLInputElement).files, 'plan')" />
@@ -1609,7 +1641,7 @@ onMounted(() => {