删除按钮

master
lion 3 months ago
parent c14a96eacb
commit ca7fb897e3

@ -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 ? '更新报名' : '提交报名' }}

Loading…
Cancel
Save