diff --git a/src/views/ApplyFormView.vue b/src/views/ApplyFormView.vue index 3b6fe10..664250f 100644 --- a/src/views/ApplyFormView.vue +++ b/src/views/ApplyFormView.vue @@ -45,6 +45,7 @@ interface FileItem { size?: number url?: string previewUrl?: string + localBlobUrl?: string } interface PublicTrackRow { @@ -122,9 +123,24 @@ function authHeaders(isJson: boolean): HeadersInit { const t = localStorage.getItem(TOKEN_KEY) || '' const h: Record = { Authorization: `Bearer ${t}`, Accept: 'application/json' } if (isJson) h['Content-Type'] = 'application/json' + const s = slug.value + if (s) h['X-Competition-Slug'] = s return h } +/** 串行发起选手端 API 请求,避免与附件预览等并发占满同域连接导致保存/删除 pending */ +let participantApiChain: Promise = Promise.resolve() + +async function participantApiFetch(input: string, init?: RequestInit): Promise { + const run = () => fetch(input, init) + const task = participantApiChain.then(run, run) + participantApiChain = task.then( + () => undefined, + () => undefined, + ) + return task +} + function goLogin() { localStorage.removeItem(TOKEN_KEY) const s = slug.value @@ -317,6 +333,8 @@ watch( const planFileItems = ref([]) const supportingFileItems = ref([]) +const deletingFileIds = ref([]) +const savingDraft = ref(false) const planFileSavedInfo = ref('') const supportingFilesSavedInfo = ref('') @@ -424,12 +442,6 @@ function fileItemDisplayName(item: FileItem) { return item.file ? item.file.name : item.original_name || '' } -/** 预览/下载链上建议保存名(与展示名一致);同源时部分浏览器会采用 */ -function fileItemSuggestedDownloadName(item: FileItem): string | undefined { - const n = fileItemDisplayName(item).trim() - return n || undefined -} - function formatSubmittedAt(value: string): string { if (!value) return '' const d = new Date(value) @@ -760,7 +772,9 @@ function buildApiPayload(): Record { o[f.key] = formModel[f.key] ?? '' } if (schemaFields.value.some((f) => f.key === 'commitment_accepted')) { - o.promise_signature = formModel.promise_signature ?? '' + if (formModel.commitment_accepted === '1') { + o.promise_signature = formModel.promise_signature ?? '' + } } return o } @@ -875,7 +889,7 @@ async function uploadNewFiles(target: 'plan' | 'supporting') { const fd = new FormData() fd.append('kind', target === 'plan' ? 'plan' : 'supporting') fd.append('file', item.file) - const r = await fetch(`${apiBase()}/api/applications/current/files${competitionQuery()}`, { + const r = await participantApiFetch(`${apiBase()}/api/applications/current/files${competitionQuery()}`, { method: 'POST', headers: { Authorization: `Bearer ${token}`, Accept: 'application/json' }, body: fd, @@ -905,35 +919,92 @@ async function uploadNewFiles(target: 'plan' | 'supporting') { else validateSupportingFiles() } +function revokeFileBlobUrl(item: FileItem) { + if (item.localBlobUrl && item.localBlobUrl.startsWith('blob:')) { + URL.revokeObjectURL(item.localBlobUrl) + delete item.localBlobUrl + } + if (item.previewUrl && String(item.previewUrl).startsWith('blob:')) { + URL.revokeObjectURL(item.previewUrl as string) + } +} + +async function openFilePreview(item: FileItem) { + if (item.localBlobUrl) { + window.open(item.localBlobUrl, '_blank', 'noopener,noreferrer') + return + } + if (item.file && item.previewUrl?.startsWith('blob:')) { + window.open(item.previewUrl, '_blank', 'noopener,noreferrer') + return + } + const url = normalizePublicAssetUrl(item.previewUrl || item.url) + if (!url) return + if (!item.fromServer) { + window.open(url, '_blank', 'noopener,noreferrer') + return + } + try { + const r = await fetch(url, { credentials: 'omit' }) + if (!r.ok) throw new Error('fetch failed') + 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') + } +} + async function removeFile(id: string | number, target: 'plan' | 'supporting') { + const key = String(id) + if (deletingFileIds.value.includes(key)) return + let items = target === 'plan' ? [...planFileItems.value] : [...supportingFileItems.value] - const item = items.find((e) => String(e.id) === String(id)) + const item = items.find((e) => String(e.id) === key) if (!item) return - if (item.fromServer && typeof item.id === 'number') { + + const serverFileId = item.fromServer ? Number(item.id) : NaN + if (item.fromServer && Number.isFinite(serverFileId) && serverFileId > 0) { const token = localStorage.getItem(TOKEN_KEY) if (!token) { goLogin() return } - const r = await fetch( - `${apiBase()}/api/applications/current/files/${item.id}${competitionQuery()}`, - { - method: 'DELETE', - headers: authHeaders(false), - }, - ) - if (r.status === 401) { - goLogin() - return - } - if (!r.ok) { - showNotice('删除失败', '提示', 'warning') + deletingFileIds.value = [...deletingFileIds.value, key] + try { + const controller = new AbortController() + const timeoutId = window.setTimeout(() => controller.abort(), 15000) + const r = await participantApiFetch( + `${apiBase()}/api/applications/current/files/${serverFileId}/delete${competitionQuery()}`, + { + method: 'POST', + headers: authHeaders(false), + signal: controller.signal, + }, + ) + window.clearTimeout(timeoutId) + if (r.status === 401) { + goLogin() + return + } + if (r.status !== 404 && !r.ok) { + showNotice('删除失败', '提示', 'warning') + return + } + } catch (err) { + const isTimeout = err instanceof DOMException && err.name === 'AbortError' + showNotice( + isTimeout ? '删除超时,请关闭已打开的预览窗口后重试' : '删除失败', + '提示', + 'warning', + ) return + } finally { + deletingFileIds.value = deletingFileIds.value.filter((fileId) => fileId !== key) } } - if (item.previewUrl && String(item.previewUrl).startsWith('blob:')) { - URL.revokeObjectURL(item.previewUrl as string) - } + revokeFileBlobUrl(item) items = items.filter((e) => String(e.id) !== String(id)) if (target === 'plan') planFileItems.value = items else supportingFileItems.value = items @@ -1158,42 +1229,65 @@ function applyServerPayload(d: { async function loadApplicationFromServer() { const token = localStorage.getItem(TOKEN_KEY) if (!token) return false - const r = await fetch(`${apiBase()}/api/applications/current${competitionQuery()}`, { - headers: authHeaders(false), - }) - if (r.status === 401) { - localStorage.removeItem(TOKEN_KEY) - goLogin() + const controller = new AbortController() + const timeoutId = window.setTimeout(() => controller.abort(), 20000) + try { + const r = await participantApiFetch(`${apiBase()}/api/applications/current${competitionQuery()}`, { + headers: authHeaders(false), + signal: controller.signal, + }) + if (r.status === 401) { + localStorage.removeItem(TOKEN_KEY) + goLogin() + return false + } + if (!r.ok) return false + const d = (await r.json()) as Parameters[0] + applyServerPayload(d) + return true + } catch { return false + } finally { + window.clearTimeout(timeoutId) } - if (!r.ok) return false - const d = (await r.json()) as Parameters[0] - applyServerPayload(d) - return true } async function saveDraftToServer() { - const r = await fetch(`${apiBase()}/api/applications/current${competitionQuery()}`, { - method: 'PUT', - headers: authHeaders(true), - body: JSON.stringify(buildApiPayload()), - }) - if (r.status === 401) { - goLogin() - return false - } - if (!r.ok) { - void r.json().catch(() => ({})) - showNotice('草稿未能保存,请检查网络或稍后重试。', '保存失败', 'warning') - return false - } + if (savingDraft.value) return false + savingDraft.value = true + const controller = new AbortController() + const timeoutId = window.setTimeout(() => controller.abort(), 20000) try { - const d = (await r.json()) as Parameters[0] - applyServerPayload(d) - } catch { - /* 非 JSON 时保持本地状态 */ + const r = await participantApiFetch(`${apiBase()}/api/applications/current/save${competitionQuery()}`, { + method: 'POST', + headers: authHeaders(true), + body: JSON.stringify(buildApiPayload()), + signal: controller.signal, + }) + if (r.status === 401) { + goLogin() + return false + } + if (!r.ok) { + void r.json().catch(() => ({})) + showNotice('草稿未能保存,请检查网络或稍后重试。', '保存失败', 'warning') + return false + } + try { + const d = (await r.json()) as Parameters[0] + applyServerPayload(d) + } catch { + /* 非 JSON 时保持本地状态 */ + } + return true + } catch (err) { + const isTimeout = err instanceof DOMException && err.name === 'AbortError' + showNotice(isTimeout ? '保存超时,请稍后重试' : '草稿未能保存,请检查网络或稍后重试。', '保存失败', 'warning') + return false + } finally { + window.clearTimeout(timeoutId) + savingDraft.value = false } - return true } /** Laravel ValidationException:errors 为字段 → 字符串数组 */ @@ -1243,38 +1337,49 @@ function submitFailureUserMessage(status: number, body: Record) } async function submitApplicationToServer() { - const r = await fetch(`${apiBase()}/api/applications/current/submit${competitionQuery()}`, { - method: 'POST', - headers: authHeaders(true), - body: JSON.stringify(buildApiPayload()), - }) - if (r.status === 401) { - goLogin() - return false - } - if (!r.ok) { - let body: Record = {} - try { - body = (await r.json()) as Record - } catch { - /* 非 JSON */ + const controller = new AbortController() + const timeoutId = window.setTimeout(() => controller.abort(), 30000) + try { + const r = await participantApiFetch(`${apiBase()}/api/applications/current/submit${competitionQuery()}`, { + method: 'POST', + headers: authHeaders(true), + body: JSON.stringify(buildApiPayload()), + signal: controller.signal, + }) + if (r.status === 401) { + goLogin() + return false } - showNotice(submitFailureUserMessage(r.status, body), '提交失败', 'warning') + if (!r.ok) { + let body: Record = {} + try { + body = (await r.json()) as Record + } catch { + /* 非 JSON */ + } + showNotice(submitFailureUserMessage(r.status, body), '提交失败', 'warning') + return false + } + const d = (await r.json()) as Parameters[0] & { + channel_callback?: ChannelCallback | null + success_notice?: { enabled?: unknown; message?: unknown } | null + } + applyServerPayload(d) + const successNotice = d.success_notice + const successNoticeMessage = + successNotice?.enabled === true && typeof successNotice.message === 'string' + ? successNotice.message.trim() + : '' + return { + channelCallback: normalizeChannelCallback(d.channel_callback), + successNoticeMessage, + } + } catch (err) { + const isTimeout = err instanceof DOMException && err.name === 'AbortError' + showNotice(isTimeout ? '提交超时,请稍后重试' : '报名未能提交,请检查网络或稍后重试。', '提交失败', 'warning') return false - } - const d = (await r.json()) as Parameters[0] & { - channel_callback?: ChannelCallback | null - success_notice?: { enabled?: unknown; message?: unknown } | null - } - applyServerPayload(d) - const successNotice = d.success_notice - const successNoticeMessage = - successNotice?.enabled === true && typeof successNotice.message === 'string' - ? successNotice.message.trim() - : '' - return { - channelCallback: normalizeChannelCallback(d.channel_callback), - successNoticeMessage, + } finally { + window.clearTimeout(timeoutId) } } @@ -1497,21 +1602,20 @@ onMounted(() => { }}
- 查看/下载 + 查看/下载 +
@@ -1563,21 +1667,20 @@ onMounted(() => { }}
- 查看/下载 + 查看/下载 +
@@ -1789,8 +1892,13 @@ onMounted(() => {
-