|
|
|
|
@ -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<string, string> = { 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<unknown> = Promise.resolve()
|
|
|
|
|
|
|
|
|
|
async function participantApiFetch(input: string, init?: RequestInit): Promise<Response> {
|
|
|
|
|
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<FileItem[]>([])
|
|
|
|
|
const supportingFileItems = ref<FileItem[]>([])
|
|
|
|
|
const deletingFileIds = ref<string[]>([])
|
|
|
|
|
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<string, unknown> {
|
|
|
|
|
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<typeof applyServerPayload>[0]
|
|
|
|
|
applyServerPayload(d)
|
|
|
|
|
return true
|
|
|
|
|
} catch {
|
|
|
|
|
return false
|
|
|
|
|
} finally {
|
|
|
|
|
window.clearTimeout(timeoutId)
|
|
|
|
|
}
|
|
|
|
|
if (!r.ok) return false
|
|
|
|
|
const d = (await r.json()) as Parameters<typeof applyServerPayload>[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<typeof applyServerPayload>[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<typeof applyServerPayload>[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<string, unknown>)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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<string, unknown> = {}
|
|
|
|
|
try {
|
|
|
|
|
body = (await r.json()) as Record<string, unknown>
|
|
|
|
|
} 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<string, unknown> = {}
|
|
|
|
|
try {
|
|
|
|
|
body = (await r.json()) as Record<string, unknown>
|
|
|
|
|
} catch {
|
|
|
|
|
/* 非 JSON */
|
|
|
|
|
}
|
|
|
|
|
showNotice(submitFailureUserMessage(r.status, body), '提交失败', 'warning')
|
|
|
|
|
return false
|
|
|
|
|
}
|
|
|
|
|
const d = (await r.json()) as Parameters<typeof applyServerPayload>[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<typeof applyServerPayload>[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(() => {
|
|
|
|
|
}}</span>
|
|
|
|
|
</div>
|
|
|
|
|
<div class="supporting-file-actions">
|
|
|
|
|
<a
|
|
|
|
|
<button
|
|
|
|
|
type="button"
|
|
|
|
|
class="btn btn-sm btn-outline-primary"
|
|
|
|
|
:href="item.previewUrl || item.url || '#'"
|
|
|
|
|
:download="fileItemSuggestedDownloadName(item)"
|
|
|
|
|
target="_blank"
|
|
|
|
|
rel="noopener"
|
|
|
|
|
>查看/下载</a
|
|
|
|
|
@click="openFilePreview(item)"
|
|
|
|
|
>
|
|
|
|
|
查看/下载
|
|
|
|
|
</button>
|
|
|
|
|
<button
|
|
|
|
|
type="button"
|
|
|
|
|
class="btn btn-sm btn-outline-danger"
|
|
|
|
|
:disabled="formDisabled"
|
|
|
|
|
:disabled="formDisabled || deletingFileIds.includes(String(item.id))"
|
|
|
|
|
@click="removeFile(item.id, 'plan')"
|
|
|
|
|
>
|
|
|
|
|
删除
|
|
|
|
|
{{ deletingFileIds.includes(String(item.id)) ? '删除中…' : '删除' }}
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
@ -1563,21 +1667,20 @@ onMounted(() => {
|
|
|
|
|
}}</span>
|
|
|
|
|
</div>
|
|
|
|
|
<div class="supporting-file-actions">
|
|
|
|
|
<a
|
|
|
|
|
<button
|
|
|
|
|
type="button"
|
|
|
|
|
class="btn btn-sm btn-outline-primary"
|
|
|
|
|
:href="item.previewUrl || item.url || '#'"
|
|
|
|
|
:download="fileItemSuggestedDownloadName(item)"
|
|
|
|
|
target="_blank"
|
|
|
|
|
rel="noopener"
|
|
|
|
|
>查看/下载</a
|
|
|
|
|
@click="openFilePreview(item)"
|
|
|
|
|
>
|
|
|
|
|
查看/下载
|
|
|
|
|
</button>
|
|
|
|
|
<button
|
|
|
|
|
type="button"
|
|
|
|
|
class="btn btn-sm btn-outline-danger"
|
|
|
|
|
:disabled="formDisabled"
|
|
|
|
|
:disabled="formDisabled || deletingFileIds.includes(String(item.id))"
|
|
|
|
|
@click="removeFile(item.id, 'supporting')"
|
|
|
|
|
>
|
|
|
|
|
删除
|
|
|
|
|
{{ deletingFileIds.includes(String(item.id)) ? '删除中…' : '删除' }}
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
@ -1789,8 +1892,13 @@ onMounted(() => {
|
|
|
|
|
</div>
|
|
|
|
|
</template>
|
|
|
|
|
<div class="col-12 apply-form-actions mt-3 pt-3 border-top d-flex gap-2 flex-wrap">
|
|
|
|
|
<button type="button" class="btn btn-outline-primary" :disabled="formDisabled" @click="onSaveClick">
|
|
|
|
|
{{ isSubmitted ? '保存修改' : '保存' }}
|
|
|
|
|
<button
|
|
|
|
|
type="button"
|
|
|
|
|
class="btn btn-outline-primary"
|
|
|
|
|
:disabled="formDisabled || savingDraft"
|
|
|
|
|
@click="onSaveClick"
|
|
|
|
|
>
|
|
|
|
|
{{ savingDraft ? '保存中…' : isSubmitted ? '保存修改' : '保存' }}
|
|
|
|
|
</button>
|
|
|
|
|
<button type="button" class="btn btn-success" :disabled="formDisabled" @click="onSubmitClick">
|
|
|
|
|
{{ isSubmitted ? '更新报名' : '提交报名' }}
|
|
|
|
|
|