附件上传临时开启

master
lion 2 months ago
parent 33863caeef
commit 049545613e

@ -53,11 +53,15 @@ export async function downloadAdminApplicationFile(
fileId: number,
fallbackName: string,
): Promise<void> {
const res = await adminHttp.get<Blob>(
`/competitions/${competitionId}/applications/${applicationId}/files/${fileId}/download`,
{ responseType: 'blob' },
)
triggerBlobDownload(res.data, fallbackName || '附件')
try {
const res = await adminHttp.get<Blob>(
`/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<Error> {
async function readBlobApiError(error: unknown, fallback: string): Promise<Error> {
if (axios.isAxiosError(error)) {
const data = error.response?.data
if (data instanceof Blob) {
@ -93,8 +97,9 @@ async function readExportError(error: unknown): Promise<Error> {
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<Error> {
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<Error> {
return readBlobApiError(error, '导出失败')
}
function sleep(ms: number): Promise<void> {

@ -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

@ -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<HTMLFormElement | null>(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')"
/>
<div class="apply-file-status" :class="{ 'apply-file-status--filled': planFileItems.length > 0 }">
@ -1609,7 +1641,7 @@ onMounted(() => {
<button
type="button"
class="btn btn-sm btn-outline-danger"
:disabled="formDisabled || deletingFileIds.includes(String(item.id))"
:disabled="fileControlsDisabled || deletingFileIds.includes(String(item.id))"
@click="removeFile(item.id, 'plan')"
>
{{ deletingFileIds.includes(String(item.id)) ? '删除中…' : '删除' }}
@ -1638,7 +1670,7 @@ onMounted(() => {
:class="{ 'is-invalid': wasValidated && !!supportingFilesFeedback }"
:accept="fileInputAcceptAttr(field)"
multiple
:disabled="formDisabled"
:disabled="fileControlsDisabled"
@change="addFiles(($event.target as HTMLInputElement).files, 'supporting')"
/>
<div class="apply-file-status" :class="{ 'apply-file-status--filled': supportingFileItems.length > 0 }">
@ -1674,7 +1706,7 @@ onMounted(() => {
<button
type="button"
class="btn btn-sm btn-outline-danger"
:disabled="formDisabled || deletingFileIds.includes(String(item.id))"
:disabled="fileControlsDisabled || deletingFileIds.includes(String(item.id))"
@click="removeFile(item.id, 'supporting')"
>
{{ deletingFileIds.includes(String(item.id)) ? '删除中…' : '删除' }}

@ -37,6 +37,7 @@ import type {
CompetitionPayload,
CompetitionSuccessNoticeSettings,
CompetitionReviewPortalSettings,
CompetitionPostCloseFileEditSettings,
CompetitionRow,
CompetitionTrackPayload,
CompetitionTrackRow,
@ -99,6 +100,8 @@ const form = ref({
success_notice_message: '',
review_portal_enabled: true,
review_portal_message: '',
post_close_file_edit_enabled: false,
post_close_file_edit_mobiles: '',
})
const brand = ref<BrandingForm>(emptyBrandingForm())
@ -245,6 +248,37 @@ function reviewPortalFromSettings(settings: unknown): CompetitionReviewPortalSet
}
}
function postCloseFileEditFromSettings(settings: unknown): CompetitionPostCloseFileEditSettings {
const raw =
settings && typeof settings === 'object' && !Array.isArray(settings)
? (settings as Record<string, unknown>).post_close_file_edit
: null
const cfg =
raw && typeof raw === 'object' && !Array.isArray(raw) ? (raw as Record<string, unknown>) : null
if (!cfg) {
return { enabled: false, user_mobiles: [] }
}
const mobilesRaw = Array.isArray(cfg.user_mobiles) ? cfg.user_mobiles : []
const user_mobiles = mobilesRaw
.map((m) => String(m ?? '').replace(/\s+/g, '').trim())
.filter((m) => /^1[3-9]\d{9}$/.test(m))
return {
enabled: cfg.enabled === true || cfg.enabled === 1 || cfg.enabled === '1',
user_mobiles: [...new Set(user_mobiles)],
}
}
function parsePostCloseFileEditMobilesText(text: string): string[] {
const parts = text
.split(/[\n,,;;\s]+/)
.map((m) => m.replace(/\s+/g, '').trim())
.filter(Boolean)
return [...new Set(parts)]
}
function settingsWithSuccessNotice(): Record<string, unknown> | null {
const settings = { ...rawSettings.value }
const enabled = form.value.success_notice_enabled
@ -260,6 +294,16 @@ function settingsWithSuccessNotice(): Record<string, unknown> | null {
message: form.value.review_portal_message.trim(),
}
const mobiles = parsePostCloseFileEditMobilesText(form.value.post_close_file_edit_mobiles)
if (form.value.post_close_file_edit_enabled) {
settings.post_close_file_edit = {
enabled: true,
user_mobiles: mobiles,
}
} else {
delete settings.post_close_file_edit
}
return Object.keys(settings).length > 0 ? settings : null
}
@ -501,6 +545,8 @@ async function loadDetail() {
success_notice_message: '',
review_portal_enabled: true,
review_portal_message: '',
post_close_file_edit_enabled: false,
post_close_file_edit_mobiles: '',
}
rawSettings.value = {}
return
@ -532,6 +578,8 @@ async function loadDetail() {
success_notice_message: successNoticeFromSettings(row.settings).message,
review_portal_enabled: reviewPortalFromSettings(row.settings).enabled,
review_portal_message: reviewPortalFromSettings(row.settings).message,
post_close_file_edit_enabled: postCloseFileEditFromSettings(row.settings).enabled,
post_close_file_edit_mobiles: postCloseFileEditFromSettings(row.settings).user_mobiles.join('\n'),
}
rawSettings.value =
row.settings && typeof row.settings === 'object' && !Array.isArray(row.settings)
@ -589,6 +637,18 @@ async function saveBasic() {
ElMessage.warning('关闭评审后,请填写评审端说明文字')
return
}
if (form.value.post_close_file_edit_enabled) {
const mobiles = parsePostCloseFileEditMobilesText(form.value.post_close_file_edit_mobiles)
if (mobiles.length === 0) {
ElMessage.warning('开启截止后补传附件后,请至少填写一个登录手机号')
return
}
const invalid = mobiles.find((m) => !/^1[3-9]\d{9}$/.test(m))
if (invalid) {
ElMessage.warning(`手机号格式无效:${invalid}`)
return
}
}
const creatingNew = route.name === 'admin-competition-new'
if (creatingNew) {
@ -1459,6 +1519,27 @@ onMounted(() => {
/>
</el-form-item>
</el-col>
<el-col :span="24">
<el-form-item label="截止后补传附件">
<el-switch
v-model="form.post_close_file_edit_enabled"
active-text="开启"
inactive-text="关闭"
/>
<p class="form-hint mt-2">
开启后,报名截止后仅白名单内登录手机号可上传/删除附件;其他报名信息仍不可修改,且无需再次提交。若项目已有评委评审/打分,仍不可改附件。
</p>
<el-input
v-if="form.post_close_file_edit_enabled"
v-model="form.post_close_file_edit_mobiles"
type="textarea"
:rows="4"
maxlength="2000"
show-word-limit
placeholder="每行一个登录手机号,也可用逗号分隔,例如:&#10;13800138000&#10;13900139000"
/>
</el-form-item>
</el-col>
<el-col :xs="24" :sm="12" :md="8">
<el-form-item label="状态">
<el-select v-model="form.status" class="w-100">

@ -129,8 +129,8 @@ async function downloadFile(file: AdminApplicationFileRow) {
if (!cid || !app) return
try {
await downloadAdminApplicationFile(cid, app.id, file.id, file.original_name || '附件')
} catch {
ElMessage.error('下载失败')
} catch (e) {
ElMessage.error(e instanceof Error ? e.message : '下载附件失败')
}
}

@ -361,10 +361,19 @@ async function downloadAttachment(f: FileItem) {
const url = reviewApplicationFileDownloadUrl(parseInt(id, 10), f.id, s)
try {
const r = await fetch(url, {
headers: { Authorization: `Bearer ${token}`, Accept: '*/*' },
headers: { Authorization: `Bearer ${token}`, Accept: 'application/json,*/*' },
})
if (!r.ok) {
ElMessage.error('下载失败')
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
}
ElMessage.error(message)
return
}
const blob = await r.blob()

Loading…
Cancel
Save