|
|
|
|
@ -45,6 +45,7 @@ const slug = computed(() => String(route.params.slug ?? '').trim())
|
|
|
|
|
|
|
|
|
|
const maxFileSize = 20 * 1024 * 1024
|
|
|
|
|
const MOBILE_PATTERN = '^1[3-9]\\d{9}$'
|
|
|
|
|
const EMAIL_LOOSE_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
|
|
|
|
|
|
|
|
|
|
interface FileItem {
|
|
|
|
|
id: string | number
|
|
|
|
|
@ -266,6 +267,15 @@ const brand = ref<BrandingForm>(emptyBrandingForm())
|
|
|
|
|
const loadError = ref('')
|
|
|
|
|
|
|
|
|
|
const formModel = reactive<Record<string, string>>({})
|
|
|
|
|
const formRevision = ref(0)
|
|
|
|
|
let applyingServerPayload = false
|
|
|
|
|
watch(
|
|
|
|
|
formModel,
|
|
|
|
|
() => {
|
|
|
|
|
if (!applyingServerPayload) formRevision.value += 1
|
|
|
|
|
},
|
|
|
|
|
{ deep: true, flush: 'sync' },
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
function fieldLabel(field: SignupFormSchemaField): string {
|
|
|
|
|
return localizedFieldText(field, portalLocale.value, 'label') || field.label
|
|
|
|
|
@ -581,8 +591,23 @@ function showNotice(message: string, title?: string, type: 'success' | 'warning'
|
|
|
|
|
resolve()
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
el.addEventListener('hidden.bs.modal', () => resolve(), { once: true })
|
|
|
|
|
Modal.getOrCreateInstance(el).show()
|
|
|
|
|
const inst = Modal.getOrCreateInstance(el)
|
|
|
|
|
const open = () => {
|
|
|
|
|
el.addEventListener('hidden.bs.modal', () => resolve(), { once: true })
|
|
|
|
|
inst.show()
|
|
|
|
|
}
|
|
|
|
|
if (el.classList.contains('show')) {
|
|
|
|
|
el.addEventListener(
|
|
|
|
|
'hidden.bs.modal',
|
|
|
|
|
() => {
|
|
|
|
|
window.setTimeout(open, 50)
|
|
|
|
|
},
|
|
|
|
|
{ once: true },
|
|
|
|
|
)
|
|
|
|
|
inst.hide()
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
open()
|
|
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
@ -1315,6 +1340,8 @@ function applyServerPayload(d: {
|
|
|
|
|
submitted_at?: string | null
|
|
|
|
|
files?: { id: number; kind: string; original_name: string; size: number; url: string }[]
|
|
|
|
|
} & Record<string, unknown>) {
|
|
|
|
|
applyingServerPayload = true
|
|
|
|
|
try {
|
|
|
|
|
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
|
|
|
|
|
@ -1377,6 +1404,9 @@ function applyServerPayload(d: {
|
|
|
|
|
})
|
|
|
|
|
validatePlanFile()
|
|
|
|
|
validateSupportingFiles()
|
|
|
|
|
} finally {
|
|
|
|
|
applyingServerPayload = false
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function loadApplicationFromServer() {
|
|
|
|
|
@ -1418,6 +1448,7 @@ async function loadApplicationFromServer() {
|
|
|
|
|
async function saveDraftToServer() {
|
|
|
|
|
if (savingDraft.value) return false
|
|
|
|
|
savingDraft.value = true
|
|
|
|
|
const epochAtStart = formRevision.value
|
|
|
|
|
const controller = new AbortController()
|
|
|
|
|
const timeoutId = window.setTimeout(() => controller.abort(), 20000)
|
|
|
|
|
try {
|
|
|
|
|
@ -1432,13 +1463,20 @@ async function saveDraftToServer() {
|
|
|
|
|
return false
|
|
|
|
|
}
|
|
|
|
|
if (!r.ok) {
|
|
|
|
|
void r.json().catch(() => ({}))
|
|
|
|
|
showNotice(tx('apply.draftFailed'), tx('apply.saveFailed'), 'warning')
|
|
|
|
|
let body: Record<string, unknown> = {}
|
|
|
|
|
try {
|
|
|
|
|
body = (await r.json()) as Record<string, unknown>
|
|
|
|
|
} catch {
|
|
|
|
|
/* 非 JSON */
|
|
|
|
|
}
|
|
|
|
|
showNotice(saveFailureUserMessage(r.status, body), tx('apply.saveFailed'), 'warning')
|
|
|
|
|
return false
|
|
|
|
|
}
|
|
|
|
|
try {
|
|
|
|
|
const d = (await r.json()) as Parameters<typeof applyServerPayload>[0]
|
|
|
|
|
applyServerPayload(d)
|
|
|
|
|
if (formRevision.value === epochAtStart) {
|
|
|
|
|
applyServerPayload(d)
|
|
|
|
|
}
|
|
|
|
|
} catch {
|
|
|
|
|
/* 非 JSON 时保持本地状态 */
|
|
|
|
|
}
|
|
|
|
|
@ -1453,22 +1491,6 @@ async function saveDraftToServer() {
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** Laravel ValidationException:errors 为字段 → 字符串数组 */
|
|
|
|
|
function firstMessagesFromLaravelErrors(errors: unknown): string[] {
|
|
|
|
|
if (errors == null || typeof errors !== 'object' || Array.isArray(errors)) return []
|
|
|
|
|
const out: string[] = []
|
|
|
|
|
for (const v of Object.values(errors as Record<string, unknown>)) {
|
|
|
|
|
if (Array.isArray(v)) {
|
|
|
|
|
for (const s of v) {
|
|
|
|
|
if (typeof s === 'string' && s.trim()) out.push(s.trim())
|
|
|
|
|
}
|
|
|
|
|
} else if (typeof v === 'string' && v.trim()) {
|
|
|
|
|
out.push(v.trim())
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return out
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function isGenericLaravelInvalidMessage(message: string): boolean {
|
|
|
|
|
const m = message.trim()
|
|
|
|
|
return (
|
|
|
|
|
@ -1479,16 +1501,72 @@ function isGenericLaravelInvalidMessage(message: string): boolean {
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** 按接口返回组装选手可见的提交失败说明(优先业务 errors / message,再按状态码兜底) */
|
|
|
|
|
function submitFailureUserMessage(status: number, body: Record<string, unknown>): string {
|
|
|
|
|
const fromFields = firstMessagesFromLaravelErrors(body.errors)
|
|
|
|
|
if (fromFields.length > 0) return fromFields[0]
|
|
|
|
|
function signupFieldLabel(key: string): string {
|
|
|
|
|
const bare = key.includes('.') ? key.slice(key.lastIndexOf('.') + 1) : key
|
|
|
|
|
const field = schemaFields.value.find((f) => f.key === key || f.key === bare)
|
|
|
|
|
if (field) return fieldLabel(field)
|
|
|
|
|
if (bare === 'promise_signature' || bare === 'commitment_accepted') return tx('apply.pledgeTitle')
|
|
|
|
|
return bare
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function humanizeSignupFieldError(key: string, message: string): string {
|
|
|
|
|
const label = signupFieldLabel(key)
|
|
|
|
|
const m = message.trim()
|
|
|
|
|
const lower = m.toLowerCase()
|
|
|
|
|
if (key === 'contact_email' || key.endsWith('.contact_email') || /valid email/.test(lower)) {
|
|
|
|
|
if (/required|必填|不能为空/.test(lower) || /required/.test(lower)) {
|
|
|
|
|
return tx('apply.pleaseFill', { label })
|
|
|
|
|
}
|
|
|
|
|
return tx('apply.invalidEmail')
|
|
|
|
|
}
|
|
|
|
|
if (key === 'contact_mobile' || key.endsWith('.contact_mobile')) {
|
|
|
|
|
if (/required|必填|不能为空/.test(lower)) return tx('apply.pleaseFill', { label })
|
|
|
|
|
return tx('apply.invalidMobile')
|
|
|
|
|
}
|
|
|
|
|
if (/required|必填|不能为空/.test(lower)) return tx('apply.pleaseFill', { label })
|
|
|
|
|
if (/the selected .+ is invalid|invalid/.test(lower) && /selected|in /.test(lower)) {
|
|
|
|
|
return tx('apply.pleaseSelectField', { label })
|
|
|
|
|
}
|
|
|
|
|
const max = m.match(/may not be greater than (\d+)/i) || m.match(/不能大于\s*(\d+)/)
|
|
|
|
|
if (max) return tx('apply.maxChars', { label, n: max[1] })
|
|
|
|
|
if (/[\u4e00-\u9fff]/.test(m)) return m
|
|
|
|
|
return tx('apply.fieldInvalid', { label })
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function signupValidationUserMessages(errors: unknown): string[] {
|
|
|
|
|
if (errors == null || typeof errors !== 'object' || Array.isArray(errors)) return []
|
|
|
|
|
const rec = errors as Record<string, unknown>
|
|
|
|
|
const out: string[] = []
|
|
|
|
|
const seen = new Set<string>()
|
|
|
|
|
for (const [key, value] of Object.entries(rec)) {
|
|
|
|
|
const parts = Array.isArray(value) ? value : [value]
|
|
|
|
|
for (const part of parts) {
|
|
|
|
|
if (typeof part !== 'string' || !part.trim()) continue
|
|
|
|
|
const msg = humanizeSignupFieldError(key, part.trim())
|
|
|
|
|
if (seen.has(msg)) continue
|
|
|
|
|
seen.add(msg)
|
|
|
|
|
out.push(msg)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return out
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function mappedOrLaravelValidationMessage(body: Record<string, unknown>): string | null {
|
|
|
|
|
const mapped = signupValidationUserMessages(body.errors)
|
|
|
|
|
if (mapped.length > 0) return mapped.join('\n')
|
|
|
|
|
|
|
|
|
|
const rawMsg = body.message
|
|
|
|
|
if (typeof rawMsg === 'string' && rawMsg.trim()) {
|
|
|
|
|
const msg = rawMsg.trim()
|
|
|
|
|
if (!isGenericLaravelInvalidMessage(msg)) return msg
|
|
|
|
|
}
|
|
|
|
|
return null
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** 按接口返回组装选手可见的提交失败说明(优先业务 errors / message,再按状态码兜底) */
|
|
|
|
|
function submitFailureUserMessage(status: number, body: Record<string, unknown>): string {
|
|
|
|
|
const specific = mappedOrLaravelValidationMessage(body)
|
|
|
|
|
if (specific) return specific
|
|
|
|
|
|
|
|
|
|
if (status === 404) return tx('apply.submitNotFound')
|
|
|
|
|
if (status === 403) return tx('apply.submitForbidden')
|
|
|
|
|
@ -1499,6 +1577,19 @@ function submitFailureUserMessage(status: number, body: Record<string, unknown>)
|
|
|
|
|
return tx('apply.submitFailed')
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** 暂存失败:校验错误展示字段原因,网络类问题才用草稿未能保存 */
|
|
|
|
|
function saveFailureUserMessage(status: number, body: Record<string, unknown>): string {
|
|
|
|
|
const specific = mappedOrLaravelValidationMessage(body)
|
|
|
|
|
if (specific) return specific
|
|
|
|
|
|
|
|
|
|
if (status === 404) return tx('apply.competitionMissing')
|
|
|
|
|
if (status === 403) return tx('apply.windowClosed')
|
|
|
|
|
if (status === 429) return tx('apply.submitBusy')
|
|
|
|
|
if (status >= 500) return tx('apply.submitServer')
|
|
|
|
|
if (status === 422) return tx('apply.saveInvalid')
|
|
|
|
|
return tx('apply.draftFailed')
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function submitApplicationToServer() {
|
|
|
|
|
const controller = new AbortController()
|
|
|
|
|
const timeoutId = window.setTimeout(() => controller.abort(), 30000)
|
|
|
|
|
@ -1549,11 +1640,49 @@ async function submitApplicationToServer() {
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function syncFormModelFromDom() {
|
|
|
|
|
const form = applyFormEl.value
|
|
|
|
|
if (!form) return
|
|
|
|
|
for (const field of schemaFields.value) {
|
|
|
|
|
if (field.type === 'file' || field.type === 'checkbox') continue
|
|
|
|
|
const el = form.elements.namedItem(field.key)
|
|
|
|
|
if (!el || el instanceof RadioNodeList) continue
|
|
|
|
|
if (el instanceof HTMLInputElement || el instanceof HTMLSelectElement || el instanceof HTMLTextAreaElement) {
|
|
|
|
|
formModel[field.key] = el.value
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
const other = form.elements.namedItem(TRACK_OTHER_FIELD_KEY)
|
|
|
|
|
if (other instanceof HTMLInputElement || other instanceof HTMLTextAreaElement) {
|
|
|
|
|
formModel[TRACK_OTHER_FIELD_KEY] = other.value
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function validateDraftFormats(): boolean {
|
|
|
|
|
const problems: string[] = []
|
|
|
|
|
for (const field of schemaFields.value) {
|
|
|
|
|
if (!isFieldVisible(field) || field.type === 'file' || field.type === 'checkbox') continue
|
|
|
|
|
const v = String(formModel[field.key] ?? '').trim()
|
|
|
|
|
if (!v) continue
|
|
|
|
|
if ((field.type === 'email' || field.key === 'contact_email') && !EMAIL_LOOSE_RE.test(v)) {
|
|
|
|
|
problems.push(tx('apply.invalidEmail'))
|
|
|
|
|
}
|
|
|
|
|
if ((field.type === 'tel' || field.key === 'contact_mobile') && !/^1[3-9]\d{9}$/.test(v)) {
|
|
|
|
|
problems.push(tx('apply.invalidMobile'))
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if (problems.length === 0) return true
|
|
|
|
|
wasValidated.value = true
|
|
|
|
|
showNotice([...new Set(problems)].join('\n'), tx('apply.saveFailed'), 'warning')
|
|
|
|
|
return false
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function onSaveClick() {
|
|
|
|
|
if (!localStorage.getItem(TOKEN_KEY)) {
|
|
|
|
|
goLogin()
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
syncFormModelFromDom()
|
|
|
|
|
if (!validateDraftFormats()) return
|
|
|
|
|
const ok = await saveDraftToServer()
|
|
|
|
|
if (!ok) return
|
|
|
|
|
saveDraftLocal()
|
|
|
|
|
@ -1565,6 +1694,7 @@ async function onSubmitClick() {
|
|
|
|
|
goLogin()
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
syncFormModelFromDom()
|
|
|
|
|
if (!validateForm()) return
|
|
|
|
|
const result = await submitApplicationToServer()
|
|
|
|
|
if (!result) return
|
|
|
|
|
@ -1657,6 +1787,7 @@ onMounted(() => {
|
|
|
|
|
ref="applyFormEl"
|
|
|
|
|
class="row g-3"
|
|
|
|
|
:class="{ 'was-validated': wasValidated }"
|
|
|
|
|
novalidate
|
|
|
|
|
@submit.prevent
|
|
|
|
|
>
|
|
|
|
|
<template v-for="field in orderedSignupFields" :key="field.type === 'file' ? 'f-' + field.key : field.key">
|
|
|
|
|
@ -1673,6 +1804,7 @@ onMounted(() => {
|
|
|
|
|
<div class="track-custom-select w-100">
|
|
|
|
|
<select
|
|
|
|
|
id="track"
|
|
|
|
|
name="track"
|
|
|
|
|
ref="trackHiddenSelect"
|
|
|
|
|
v-model="formModel.track"
|
|
|
|
|
class="visually-hidden"
|
|
|
|
|
@ -1724,6 +1856,7 @@ onMounted(() => {
|
|
|
|
|
<input
|
|
|
|
|
v-if="isOtherTrack(formModel.track)"
|
|
|
|
|
:id="TRACK_OTHER_FIELD_KEY"
|
|
|
|
|
:name="TRACK_OTHER_FIELD_KEY"
|
|
|
|
|
v-model="formModel[TRACK_OTHER_FIELD_KEY]"
|
|
|
|
|
class="form-control editable mt-2"
|
|
|
|
|
type="text"
|
|
|
|
|
@ -1915,6 +2048,7 @@ onMounted(() => {
|
|
|
|
|
>
|
|
|
|
|
</label>
|
|
|
|
|
<select
|
|
|
|
|
:name="field.key"
|
|
|
|
|
v-model="formModel[field.key]"
|
|
|
|
|
class="form-select editable"
|
|
|
|
|
:required="effectiveRequired(field)"
|
|
|
|
|
@ -1944,6 +2078,7 @@ onMounted(() => {
|
|
|
|
|
>
|
|
|
|
|
</label>
|
|
|
|
|
<select
|
|
|
|
|
name="location_city"
|
|
|
|
|
v-model="formModel.location_city"
|
|
|
|
|
class="form-select editable"
|
|
|
|
|
:required="effectiveRequired(field)"
|
|
|
|
|
@ -2039,6 +2174,7 @@ onMounted(() => {
|
|
|
|
|
>
|
|
|
|
|
</label>
|
|
|
|
|
<textarea
|
|
|
|
|
:name="field.key"
|
|
|
|
|
v-model="formModel[field.key]"
|
|
|
|
|
class="form-control editable"
|
|
|
|
|
rows="4"
|
|
|
|
|
@ -2078,13 +2214,15 @@ onMounted(() => {
|
|
|
|
|
>
|
|
|
|
|
</label>
|
|
|
|
|
<input
|
|
|
|
|
:name="field.key"
|
|
|
|
|
v-model="formModel[field.key]"
|
|
|
|
|
:type="
|
|
|
|
|
field.type === 'email'
|
|
|
|
|
:type="field.key === 'contact_mobile' ? 'tel' : 'text'"
|
|
|
|
|
:inputmode="
|
|
|
|
|
field.type === 'email' || field.key === 'contact_email'
|
|
|
|
|
? 'email'
|
|
|
|
|
: field.key === 'contact_mobile'
|
|
|
|
|
? 'tel'
|
|
|
|
|
: 'text'
|
|
|
|
|
: undefined
|
|
|
|
|
"
|
|
|
|
|
class="form-control editable"
|
|
|
|
|
:class="{ 'company-name-input': field.key === 'company_name' }"
|
|
|
|
|
@ -2092,7 +2230,13 @@ onMounted(() => {
|
|
|
|
|
:disabled="formDisabled"
|
|
|
|
|
:placeholder="fieldPlaceholderText(field)"
|
|
|
|
|
:pattern="field.key === 'contact_mobile' ? MOBILE_PATTERN : undefined"
|
|
|
|
|
:autocomplete="field.key === 'company_name' ? 'organization' : undefined"
|
|
|
|
|
:autocomplete="
|
|
|
|
|
field.key === 'company_name'
|
|
|
|
|
? 'organization'
|
|
|
|
|
: field.type === 'email' || field.key === 'contact_email'
|
|
|
|
|
? 'email'
|
|
|
|
|
: undefined
|
|
|
|
|
"
|
|
|
|
|
/>
|
|
|
|
|
<div class="invalid-feedback">{{ nativeInputInvalidFeedback(field) }}</div>
|
|
|
|
|
<small
|
|
|
|
|
|