|
|
<script setup lang="ts">
|
|
|
import { ref, reactive, computed, watch, onMounted, onBeforeUnmount, nextTick, type Ref } from 'vue'
|
|
|
import { useRouter, useRoute } from 'vue-router'
|
|
|
import { Modal, Dropdown } from 'bootstrap'
|
|
|
import { getApiBase, normalizePublicAssetUrl, TOKEN_KEY } from '../config/api'
|
|
|
|
|
|
/** @types/bootstrap 未完全对齐 5.x 运行时 API */
|
|
|
type BootstrapDropdownInstance = { toggle: () => void; hide: () => void }
|
|
|
|
|
|
const DropdownCompat = Dropdown as unknown as {
|
|
|
getOrCreateInstance: (
|
|
|
element: HTMLElement,
|
|
|
options?: Record<string, unknown>,
|
|
|
) => BootstrapDropdownInstance
|
|
|
getInstance: (element: HTMLElement) => BootstrapDropdownInstance | null
|
|
|
}
|
|
|
import { cityMap, provinceOrder } from '../data/chinaRegion'
|
|
|
import {
|
|
|
brandingFormFromApi,
|
|
|
emptyBrandingForm,
|
|
|
hasVisibleText,
|
|
|
participantApplyPageTitle,
|
|
|
type BrandingForm,
|
|
|
} from '../utils/competitionBranding'
|
|
|
import { normalizeSignupSchema, type SignupFormSchemaField } from '../utils/defaultSignupFormSchema'
|
|
|
|
|
|
const router = useRouter()
|
|
|
const route = useRoute()
|
|
|
|
|
|
const slug = computed(() => String(route.params.slug ?? '').trim())
|
|
|
|
|
|
const allowedExt = ['pdf', 'ppt', 'pptx', 'doc', 'docx', 'wps', 'rar', 'zip']
|
|
|
const maxFileSize = 20 * 1024 * 1024
|
|
|
const MOBILE_PATTERN = '^1[3-9]\\d{9}$'
|
|
|
|
|
|
interface FileItem {
|
|
|
id: string | number
|
|
|
file?: File
|
|
|
fromServer?: boolean
|
|
|
original_name?: string
|
|
|
size?: number
|
|
|
url?: string
|
|
|
previewUrl?: string
|
|
|
}
|
|
|
|
|
|
interface PublicTrackRow {
|
|
|
track_code: string
|
|
|
title: string
|
|
|
description: string | null
|
|
|
sort: number
|
|
|
}
|
|
|
|
|
|
function templateRefEl<T extends HTMLElement>(r: Ref<T | T[] | null>): T | null {
|
|
|
const v = r.value
|
|
|
if (v == null) return null
|
|
|
return Array.isArray(v) ? v[0] ?? null : v
|
|
|
}
|
|
|
|
|
|
/** 过滤后台 schema 里遗留的开发者说明,不在选手端展示(规则尽量窄,避免误伤运营填写的正常说明) */
|
|
|
function participantFieldHelp(help?: string): string {
|
|
|
const t = (help ?? '').trim()
|
|
|
if (!t) return ''
|
|
|
if (
|
|
|
/applications\.|config\/contest|与提交校验|\^1\[3-9\]|kind=\s*plan|kind=\s*supporting|\\d\{9\}/i.test(t)
|
|
|
) {
|
|
|
return ''
|
|
|
}
|
|
|
return t
|
|
|
}
|
|
|
|
|
|
function apiBase() {
|
|
|
return getApiBase()
|
|
|
}
|
|
|
|
|
|
function competitionQuery(): string {
|
|
|
const s = slug.value
|
|
|
return s ? `?competition_slug=${encodeURIComponent(s)}` : ''
|
|
|
}
|
|
|
|
|
|
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'
|
|
|
return h
|
|
|
}
|
|
|
|
|
|
function goLogin() {
|
|
|
localStorage.removeItem(TOKEN_KEY)
|
|
|
const s = slug.value
|
|
|
if (s) {
|
|
|
void router.push({
|
|
|
name: 'participant-login',
|
|
|
params: { slug: s },
|
|
|
query: { redirect: route.fullPath },
|
|
|
})
|
|
|
} else {
|
|
|
void router.push('/c')
|
|
|
}
|
|
|
}
|
|
|
|
|
|
const schemaFields = ref<SignupFormSchemaField[]>(normalizeSignupSchema([]))
|
|
|
const competitionTracks = ref<PublicTrackRow[]>([])
|
|
|
/** 赛事 /public API 已成功解析后为 true,避免 tracks 尚未返回时出现黄色提示闪烁 */
|
|
|
const competitionHydrated = ref(false)
|
|
|
const competitionName = ref('')
|
|
|
/** 赛事配置的参赛承诺书正文(HTML);空则选手端用默认文案 */
|
|
|
const pledgeContentHtml = ref('')
|
|
|
|
|
|
const DEFAULT_PLEDGE_BODY_HTML =
|
|
|
'<p>本项目申报信息中所填写的各栏目内容真实、准确。本项目负责人(团队)对申报材料的真实性负完全责任。</p><p>若申报信息中存在虚假、伪造等不实情况,本项目负责人(团队)将积极配合调查,并按照有关规定接受处理。</p>'
|
|
|
|
|
|
const pledgeBodyDisplayHtml = computed(() => {
|
|
|
const h = pledgeContentHtml.value.trim()
|
|
|
return h || DEFAULT_PLEDGE_BODY_HTML
|
|
|
})
|
|
|
const brand = ref<BrandingForm>(emptyBrandingForm())
|
|
|
const loadError = ref('')
|
|
|
|
|
|
const formModel = reactive<Record<string, string>>({})
|
|
|
|
|
|
function ensureFormKeys(fields: SignupFormSchemaField[]) {
|
|
|
for (const f of fields) {
|
|
|
if (f.type === 'file') continue
|
|
|
if (!(f.key in formModel)) formModel[f.key] = ''
|
|
|
}
|
|
|
if (fields.some((f) => f.key === 'commitment_accepted') && !('promise_signature' in formModel)) {
|
|
|
formModel.promise_signature = ''
|
|
|
}
|
|
|
}
|
|
|
|
|
|
const STORAGE_KEY = computed(() => `jscc_signup_form_draft_v2_${slug.value || 'x'}`)
|
|
|
|
|
|
const applicationStatus = ref<'draft' | 'submitted'>('draft')
|
|
|
/** 与后端 participant_may_edit 对齐:有评审记录后为 false,表单整表禁用 */
|
|
|
const participantMayEdit = ref(true)
|
|
|
|
|
|
const trackInvalid = ref(false)
|
|
|
const trackHiddenSelect = ref<HTMLSelectElement | HTMLSelectElement[] | null>(null)
|
|
|
const trackDropdownBtn = ref<HTMLButtonElement | HTMLButtonElement[] | null>(null)
|
|
|
|
|
|
/** 与原型一致:主文案 + 简介(赛事 tracks API 的 description) */
|
|
|
const trackMainText = computed(() => {
|
|
|
const code = formModel.track
|
|
|
const t = competitionTracks.value.find((x) => x.track_code === code)
|
|
|
return t ? t.title : code ? code : '请选择'
|
|
|
})
|
|
|
const trackMainClass = computed(() => (formModel.track ? 'text-body' : 'text-secondary'))
|
|
|
const trackNoteText = computed(() => {
|
|
|
const code = formModel.track
|
|
|
const t = competitionTracks.value.find((x) => x.track_code === code)
|
|
|
return (t?.description ?? '').trim()
|
|
|
})
|
|
|
|
|
|
function trackPickDescription(desc: string | null | undefined): string {
|
|
|
return (desc ?? '').trim()
|
|
|
}
|
|
|
|
|
|
function pickTrack(value: string) {
|
|
|
formModel.track = value
|
|
|
trackInvalid.value = false
|
|
|
const btn = templateRefEl(trackDropdownBtn)
|
|
|
if (btn) DropdownCompat.getInstance(btn)?.hide()
|
|
|
}
|
|
|
|
|
|
const isChina = computed(() => formModel.location_country === '中国')
|
|
|
const isOversea = computed(() => formModel.location_country === '海外')
|
|
|
|
|
|
const provinceOptions = computed(() =>
|
|
|
provinceOrder.map((p) => ({ label: p, value: p })),
|
|
|
)
|
|
|
|
|
|
const citySelectOptions = computed(() => {
|
|
|
const p = formModel.location_province
|
|
|
if (!p) return []
|
|
|
return (cityMap[p] || []).map((c) => ({ label: c, value: c }))
|
|
|
})
|
|
|
|
|
|
function syncLocationFields() {
|
|
|
if (isOversea.value) {
|
|
|
formModel.location_province = ''
|
|
|
formModel.location_city = ''
|
|
|
} else if (!isChina.value) {
|
|
|
formModel.location_province = ''
|
|
|
formModel.location_city = ''
|
|
|
formModel.oversea_country = ''
|
|
|
}
|
|
|
}
|
|
|
|
|
|
watch(
|
|
|
() => formModel.location_country,
|
|
|
() => {
|
|
|
syncLocationFields()
|
|
|
},
|
|
|
)
|
|
|
|
|
|
watch(
|
|
|
() => formModel.location_province,
|
|
|
(newProv, oldProv) => {
|
|
|
if (!isChina.value) return
|
|
|
if (newProv === oldProv) return
|
|
|
// 从「未选省」变为某省:不清空城市(服务端/草稿一次性回填省、市时避免把已回填的城市抹掉)
|
|
|
if (oldProv == null || String(oldProv).trim() === '') return
|
|
|
formModel.location_city = ''
|
|
|
},
|
|
|
)
|
|
|
|
|
|
const planFileItems = ref<FileItem[]>([])
|
|
|
const supportingFileItems = ref<FileItem[]>([])
|
|
|
const planFileSavedInfo = ref('')
|
|
|
const supportingFilesSavedInfo = ref('')
|
|
|
|
|
|
const planFileFeedback = ref('')
|
|
|
const supportingFilesFeedback = ref('')
|
|
|
|
|
|
const wasValidated = ref(false)
|
|
|
const formDisabled = computed(() => !participantMayEdit.value)
|
|
|
|
|
|
const applyFormEl = ref<HTMLFormElement | null>(null)
|
|
|
/** 在 v-for 中绑定时 Vue 可能将 ref 设为元素数组 */
|
|
|
const planFileInput = ref<HTMLInputElement | HTMLInputElement[] | null>(null)
|
|
|
const supportingFileInput = ref<HTMLInputElement | HTMLInputElement[] | null>(null)
|
|
|
|
|
|
const introCount = computed(() => (formModel.intro || '').length)
|
|
|
|
|
|
const formPageTitle = computed(() => participantApplyPageTitle(brand.value, competitionName.value))
|
|
|
|
|
|
/** 后台「报名填报页 → 副标题」:整页填报说明(与单项 field.help 不同) */
|
|
|
const applyPageSubtitle = computed(() => {
|
|
|
const s = brand.value.apply?.headerSubtitle
|
|
|
return hasVisibleText(s) ? String(s).trim() : ''
|
|
|
})
|
|
|
|
|
|
/** 原生校验失败时提示:空值与格式错误区分用语 */
|
|
|
function nativeInputInvalidFeedback(field: SignupFormSchemaField): string {
|
|
|
const v = String(formModel[field.key] ?? '').trim()
|
|
|
if (field.type === 'email' || field.key === 'contact_email') {
|
|
|
if (!v) return `请填写${field.label}`
|
|
|
return '请输入正确的邮箱格式'
|
|
|
}
|
|
|
if (field.type === 'tel' || field.key === 'contact_mobile') {
|
|
|
if (!v) return `请填写${field.label}`
|
|
|
return '请输入正确的11位中国大陆手机号'
|
|
|
}
|
|
|
return `请填写${field.label}`
|
|
|
}
|
|
|
|
|
|
function fieldColClass(field: SignupFormSchemaField): string {
|
|
|
if (field.type === 'textarea' || field.type === 'file' || field.type === 'checkbox') return 'col-12'
|
|
|
return 'col-md-4'
|
|
|
}
|
|
|
|
|
|
/** 多行文本:配置项 placeholder 改在输入框下方展示 */
|
|
|
function textareaFieldPlaceholder(field: SignupFormSchemaField): string {
|
|
|
if (field.type !== 'textarea') return ''
|
|
|
return String(field.placeholder ?? '').trim()
|
|
|
}
|
|
|
|
|
|
function isFieldVisible(field: SignupFormSchemaField): boolean {
|
|
|
if (field.type === 'file') return true
|
|
|
if (field.key === 'location_province' || field.key === 'location_city') {
|
|
|
return isChina.value
|
|
|
}
|
|
|
if (field.key === 'oversea_country') return isOversea.value
|
|
|
return true
|
|
|
}
|
|
|
|
|
|
function effectiveRequired(field: SignupFormSchemaField): boolean {
|
|
|
if (field.key === 'location_province' || field.key === 'location_city') {
|
|
|
return isChina.value
|
|
|
}
|
|
|
if (field.key === 'oversea_country') {
|
|
|
return isOversea.value
|
|
|
}
|
|
|
return field.required
|
|
|
}
|
|
|
|
|
|
/** 与 schema 顺序一致:含文件上传等非隐藏字段 */
|
|
|
const orderedSignupFields = computed(() => schemaFields.value.filter((f) => isFieldVisible(f)))
|
|
|
|
|
|
function optionsForField(field: SignupFormSchemaField): { label: string; value: string }[] {
|
|
|
if (field.key === 'track') {
|
|
|
return competitionTracks.value.map((t) => ({
|
|
|
label: t.title,
|
|
|
value: t.track_code,
|
|
|
}))
|
|
|
}
|
|
|
if (field.key === 'location_province') {
|
|
|
return provinceOptions.value
|
|
|
}
|
|
|
if (field.key === 'location_city' && isChina.value) {
|
|
|
return citySelectOptions.value
|
|
|
}
|
|
|
return field.options ?? []
|
|
|
}
|
|
|
|
|
|
function isCitySelectField(field: SignupFormSchemaField): boolean {
|
|
|
return field.key === 'location_city' && isChina.value
|
|
|
}
|
|
|
|
|
|
function formatFileSize(bytes: number) {
|
|
|
if (bytes >= 1024 * 1024) return `${(bytes / 1024 / 1024).toFixed(1).replace(/\.0$/, '')}M`
|
|
|
if (bytes >= 1024) return `${Math.ceil(bytes / 1024)}K`
|
|
|
return `${bytes}B`
|
|
|
}
|
|
|
|
|
|
function fileItemDisplayName(item: FileItem) {
|
|
|
return item.file ? item.file.name : item.original_name || ''
|
|
|
}
|
|
|
|
|
|
function formatApiErrSafe(data: Record<string, unknown>) {
|
|
|
const msg = (data.message as string) || ''
|
|
|
const errObj = data.errors as Record<string, string[]> | undefined
|
|
|
if (errObj && Object.keys(errObj).length) return `${msg}\n${JSON.stringify(errObj)}`
|
|
|
return msg || '操作失败'
|
|
|
}
|
|
|
|
|
|
const noticeModalEl = ref<HTMLElement | null>(null)
|
|
|
const noticeTitle = ref('提示')
|
|
|
const noticeBody = ref('')
|
|
|
const noticeIsSuccess = ref(false)
|
|
|
|
|
|
function showNotice(message: string, title = '提示', type: 'success' | 'warning' = 'warning') {
|
|
|
noticeTitle.value = title
|
|
|
noticeBody.value = message
|
|
|
noticeIsSuccess.value = type === 'success'
|
|
|
void nextTick(() => {
|
|
|
const el = noticeModalEl.value
|
|
|
if (el) Modal.getOrCreateInstance(el).show()
|
|
|
})
|
|
|
}
|
|
|
|
|
|
/** 与后端 `ApplicationController` 校验上限一致(base64 图片) */
|
|
|
const PROMISE_SIG_MAX_CHARS = 2_097_152
|
|
|
|
|
|
const promiseSignModalRef = ref<HTMLElement | null>(null)
|
|
|
const promiseCanvasRef = ref<HTMLCanvasElement | null>(null)
|
|
|
const promiseModalDateText = ref('')
|
|
|
const promiseSigHintVisible = ref(true)
|
|
|
|
|
|
let promiseSigCtx: CanvasRenderingContext2D | null = null
|
|
|
let promiseSigCssW = 520
|
|
|
let promiseSigCssH = 140
|
|
|
let promiseSigDrawing = false
|
|
|
let promiseSigHasInk = false
|
|
|
let promiseSigListenersAbort: AbortController | null = null
|
|
|
let promiseModalBootstrapCleanup: (() => void) | null = null
|
|
|
|
|
|
function teardownPromiseSigListeners() {
|
|
|
promiseSigListenersAbort?.abort()
|
|
|
promiseSigListenersAbort = null
|
|
|
}
|
|
|
|
|
|
const commitmentSigned = computed(
|
|
|
() => formModel.commitment_accepted === '1' && String(formModel.promise_signature ?? '').trim() !== '',
|
|
|
)
|
|
|
|
|
|
const promiseModalHeading = computed(() => {
|
|
|
const n = competitionName.value.trim()
|
|
|
return n ? `${n} 赛事承诺书` : '赛事承诺书'
|
|
|
})
|
|
|
|
|
|
function formatCnDate(d: Date): string {
|
|
|
return `${d.getFullYear()}年${d.getMonth() + 1}月${d.getDate()}日`
|
|
|
}
|
|
|
|
|
|
function promiseSigPos(
|
|
|
ev: MouseEvent | TouchEvent,
|
|
|
canvas: HTMLCanvasElement,
|
|
|
): { x: number; y: number } {
|
|
|
const rect = canvas.getBoundingClientRect()
|
|
|
if ('touches' in ev && ev.touches[0]) {
|
|
|
const t = ev.touches[0]
|
|
|
return { x: t.clientX - rect.left, y: t.clientY - rect.top }
|
|
|
}
|
|
|
const m = ev as MouseEvent
|
|
|
return { x: m.clientX - rect.left, y: m.clientY - rect.top }
|
|
|
}
|
|
|
|
|
|
function layoutPromiseCanvas() {
|
|
|
const canvas = promiseCanvasRef.value
|
|
|
if (!canvas) return
|
|
|
const wrap = canvas.closest('.promise-sig-wrap') as HTMLElement | null
|
|
|
const dpr = window.devicePixelRatio || 1
|
|
|
const canvasStyle = window.getComputedStyle(canvas)
|
|
|
promiseSigCssW = Math.max(280, Math.floor(wrap?.clientWidth || 520))
|
|
|
promiseSigCssH = Math.max(76, Math.floor(parseFloat(canvasStyle.height) || 110))
|
|
|
canvas.style.width = `${promiseSigCssW}px`
|
|
|
canvas.style.height = `${promiseSigCssH}px`
|
|
|
canvas.width = Math.floor(promiseSigCssW * dpr)
|
|
|
canvas.height = Math.floor(promiseSigCssH * dpr)
|
|
|
promiseSigCtx = canvas.getContext('2d')
|
|
|
if (!promiseSigCtx) return
|
|
|
promiseSigCtx.setTransform(1, 0, 0, 1, 0, 0)
|
|
|
promiseSigCtx.scale(dpr, dpr)
|
|
|
promiseSigCtx.fillStyle = '#ffffff'
|
|
|
promiseSigCtx.fillRect(0, 0, promiseSigCssW, promiseSigCssH)
|
|
|
promiseSigCtx.strokeStyle = '#111111'
|
|
|
promiseSigCtx.lineWidth = 2
|
|
|
promiseSigCtx.lineCap = 'round'
|
|
|
promiseSigCtx.lineJoin = 'round'
|
|
|
promiseSigHasInk = false
|
|
|
promiseSigHintVisible.value = true
|
|
|
}
|
|
|
|
|
|
function paintExistingPromiseSignature() {
|
|
|
const url = String(formModel.promise_signature ?? '').trim()
|
|
|
if (!url || !promiseSigCtx) return
|
|
|
const img = new Image()
|
|
|
img.onload = () => {
|
|
|
if (!promiseSigCtx) return
|
|
|
promiseSigCtx.drawImage(img, 0, 0, promiseSigCssW, promiseSigCssH)
|
|
|
promiseSigHasInk = true
|
|
|
promiseSigHintVisible.value = false
|
|
|
}
|
|
|
img.src = url
|
|
|
}
|
|
|
|
|
|
function bindPromiseSigCanvas() {
|
|
|
const canvas = promiseCanvasRef.value
|
|
|
if (!canvas || !promiseSigCtx) return
|
|
|
teardownPromiseSigListeners()
|
|
|
promiseSigListenersAbort = new AbortController()
|
|
|
const { signal } = promiseSigListenersAbort
|
|
|
|
|
|
const start = (ev: MouseEvent | TouchEvent) => {
|
|
|
if ('touches' in ev) ev.preventDefault()
|
|
|
if (!promiseSigCtx) return
|
|
|
promiseSigDrawing = true
|
|
|
const p = promiseSigPos(ev, canvas)
|
|
|
promiseSigCtx.beginPath()
|
|
|
promiseSigCtx.moveTo(p.x, p.y)
|
|
|
}
|
|
|
const move = (ev: MouseEvent | TouchEvent) => {
|
|
|
if (!promiseSigDrawing || !promiseSigCtx) return
|
|
|
if ('touches' in ev) ev.preventDefault()
|
|
|
const p = promiseSigPos(ev, canvas)
|
|
|
promiseSigCtx.lineTo(p.x, p.y)
|
|
|
promiseSigCtx.stroke()
|
|
|
promiseSigHasInk = true
|
|
|
promiseSigHintVisible.value = false
|
|
|
}
|
|
|
const end = () => {
|
|
|
promiseSigDrawing = false
|
|
|
}
|
|
|
|
|
|
canvas.addEventListener('mousedown', start, { signal })
|
|
|
canvas.addEventListener('mousemove', move, { signal })
|
|
|
window.addEventListener('mouseup', end, { signal })
|
|
|
canvas.addEventListener('touchstart', start, { passive: false, signal })
|
|
|
canvas.addEventListener('touchmove', move, { passive: false, signal })
|
|
|
canvas.addEventListener('touchend', end, { signal })
|
|
|
canvas.addEventListener('touchcancel', end, { signal })
|
|
|
}
|
|
|
|
|
|
/** Bootstrap 事件名含点号,勿用模板 @shown.bs.modal(Vue 会当成事件修饰符解析坏) */
|
|
|
function initPromiseCanvasAfterModalOpen() {
|
|
|
promiseModalDateText.value = formatCnDate(new Date())
|
|
|
const run = (attempt: number) => {
|
|
|
const canvas = promiseCanvasRef.value
|
|
|
if (!canvas) {
|
|
|
if (attempt < 40) requestAnimationFrame(() => run(attempt + 1))
|
|
|
return
|
|
|
}
|
|
|
layoutPromiseCanvas()
|
|
|
bindPromiseSigCanvas()
|
|
|
const existing = String(formModel.promise_signature ?? '').trim()
|
|
|
if (existing && formModel.commitment_accepted === '1') paintExistingPromiseSignature()
|
|
|
}
|
|
|
void nextTick(() => {
|
|
|
requestAnimationFrame(() => run(0))
|
|
|
})
|
|
|
}
|
|
|
|
|
|
function attachPromiseModalBootstrapListeners(el: HTMLElement) {
|
|
|
promiseModalBootstrapCleanup?.()
|
|
|
const onShown = () => {
|
|
|
initPromiseCanvasAfterModalOpen()
|
|
|
}
|
|
|
const onHidden = () => {
|
|
|
teardownPromiseSigListeners()
|
|
|
}
|
|
|
el.addEventListener('shown.bs.modal', onShown)
|
|
|
el.addEventListener('hidden.bs.modal', onHidden)
|
|
|
promiseModalBootstrapCleanup = () => {
|
|
|
el.removeEventListener('shown.bs.modal', onShown)
|
|
|
el.removeEventListener('hidden.bs.modal', onHidden)
|
|
|
promiseModalBootstrapCleanup = null
|
|
|
}
|
|
|
}
|
|
|
|
|
|
watch(
|
|
|
promiseSignModalRef,
|
|
|
(el) => {
|
|
|
promiseModalBootstrapCleanup?.()
|
|
|
if (el) attachPromiseModalBootstrapListeners(el)
|
|
|
},
|
|
|
{ flush: 'post' },
|
|
|
)
|
|
|
|
|
|
function openPromiseSignModal() {
|
|
|
if (formDisabled.value) return
|
|
|
const el = promiseSignModalRef.value
|
|
|
if (el) Modal.getOrCreateInstance(el).show()
|
|
|
}
|
|
|
|
|
|
function clearPromiseSignatureCanvas() {
|
|
|
layoutPromiseCanvas()
|
|
|
bindPromiseSigCanvas()
|
|
|
}
|
|
|
|
|
|
function confirmPromiseSignature() {
|
|
|
const canvas = promiseCanvasRef.value
|
|
|
if (!promiseSigHasInk || !canvas) {
|
|
|
showNotice('请先在签名区域内手写签名后再确认。', '请确认', 'warning')
|
|
|
return
|
|
|
}
|
|
|
const dataUrl = canvas.toDataURL('image/png')
|
|
|
if (dataUrl.length > PROMISE_SIG_MAX_CHARS) {
|
|
|
showNotice('签名数据过大,请清除后重新签名或联系管理员。', '提示', 'warning')
|
|
|
return
|
|
|
}
|
|
|
formModel.promise_signature = dataUrl
|
|
|
formModel.commitment_accepted = '1'
|
|
|
const el = promiseSignModalRef.value
|
|
|
if (el) Modal.getInstance(el)?.hide()
|
|
|
}
|
|
|
|
|
|
function buildApiPayload(): Record<string, unknown> {
|
|
|
const o: Record<string, unknown> = {}
|
|
|
for (const f of schemaFields.value) {
|
|
|
if (f.type === 'file') continue
|
|
|
if (f.type === 'checkbox') {
|
|
|
o[f.key] = formModel[f.key] === '1'
|
|
|
continue
|
|
|
}
|
|
|
o[f.key] = formModel[f.key] ?? ''
|
|
|
}
|
|
|
if (schemaFields.value.some((f) => f.key === 'commitment_accepted')) {
|
|
|
o.promise_signature = formModel.promise_signature ?? ''
|
|
|
}
|
|
|
return o
|
|
|
}
|
|
|
|
|
|
function validateFileItems(
|
|
|
items: FileItem[],
|
|
|
missingMessage: string,
|
|
|
optional: boolean,
|
|
|
): { ok: boolean; feedback: string } {
|
|
|
const realFiles = items.filter((i) => i.file)
|
|
|
const serverOnly = items.filter((i) => i.fromServer && !i.file)
|
|
|
if (!realFiles.length && !serverOnly.length) {
|
|
|
return { ok: optional, feedback: optional ? '' : missingMessage }
|
|
|
}
|
|
|
const invalidExtFile = realFiles.map((i) => i.file!).find((file) => {
|
|
|
const ext = (file.name.split('.').pop() || '').toLowerCase()
|
|
|
return !allowedExt.includes(ext)
|
|
|
})
|
|
|
if (invalidExtFile) {
|
|
|
return {
|
|
|
ok: false,
|
|
|
feedback: `“${invalidExtFile.name}”格式不支持,请上传 PDF/PPT/PPTX/DOC/DOCX/WPS/RAR/ZIP`,
|
|
|
}
|
|
|
}
|
|
|
const oversizedFile = realFiles.map((i) => i.file!).find((file) => file.size > maxFileSize)
|
|
|
if (oversizedFile) {
|
|
|
return { ok: false, feedback: `“${oversizedFile.name}”大小不能超过20M` }
|
|
|
}
|
|
|
return { ok: true, feedback: '' }
|
|
|
}
|
|
|
|
|
|
function schemaHasPlan() {
|
|
|
return schemaFields.value.some((f) => f.key === 'plan' && f.type === 'file')
|
|
|
}
|
|
|
|
|
|
function schemaHasSupporting() {
|
|
|
return schemaFields.value.some((f) => f.key === 'supporting' && f.type === 'file')
|
|
|
}
|
|
|
|
|
|
function validatePlanFile() {
|
|
|
if (!schemaHasPlan()) {
|
|
|
templateRefEl(planFileInput)?.setCustomValidity('')
|
|
|
return true
|
|
|
}
|
|
|
const r = validateFileItems(planFileItems.value, '请上传商业计划书', false)
|
|
|
planFileFeedback.value = r.feedback
|
|
|
templateRefEl(planFileInput)?.setCustomValidity(r.ok ? '' : 'missing')
|
|
|
return r.ok
|
|
|
}
|
|
|
|
|
|
function validateSupportingFiles() {
|
|
|
if (!schemaHasSupporting()) {
|
|
|
templateRefEl(supportingFileInput)?.setCustomValidity('')
|
|
|
return true
|
|
|
}
|
|
|
const r = validateFileItems(supportingFileItems.value, '文件格式或大小不符合要求', true)
|
|
|
supportingFilesFeedback.value = r.feedback
|
|
|
templateRefEl(supportingFileInput)?.setCustomValidity(r.ok ? '' : 'bad')
|
|
|
return r.ok
|
|
|
}
|
|
|
|
|
|
function validateForm(): boolean {
|
|
|
wasValidated.value = true
|
|
|
validatePlanFile()
|
|
|
validateSupportingFiles()
|
|
|
|
|
|
const trackField = schemaFields.value.find((f) => f.key === 'track')
|
|
|
if (trackField && isFieldVisible(trackField)) {
|
|
|
trackInvalid.value = !formModel.track
|
|
|
const trackEl = templateRefEl(trackHiddenSelect)
|
|
|
if (trackEl) trackEl.setCustomValidity(formModel.track ? '' : '请选择主题赛道')
|
|
|
} else {
|
|
|
trackInvalid.value = false
|
|
|
templateRefEl(trackHiddenSelect)?.setCustomValidity('')
|
|
|
}
|
|
|
|
|
|
const native = applyFormEl.value?.checkValidity() ?? false
|
|
|
if (!native) return false
|
|
|
if (trackField && effectiveRequired(trackField) && !formModel.track) return false
|
|
|
if (!validatePlanFile() || !validateSupportingFiles()) return false
|
|
|
|
|
|
const commitmentField = schemaFields.value.find((f) => f.key === 'commitment_accepted')
|
|
|
if (
|
|
|
commitmentField &&
|
|
|
(formModel.commitment_accepted !== '1' || !String(formModel.promise_signature ?? '').trim())
|
|
|
) {
|
|
|
showNotice('请先阅读并完成参赛承诺书的手写签署。', '提示', 'warning')
|
|
|
return false
|
|
|
}
|
|
|
return true
|
|
|
}
|
|
|
|
|
|
async function uploadNewFiles(target: 'plan' | 'supporting') {
|
|
|
const token = localStorage.getItem(TOKEN_KEY)
|
|
|
if (!token) return
|
|
|
const items = target === 'plan' ? planFileItems.value : supportingFileItems.value
|
|
|
const pending = items.filter((i) => i.file && !i.fromServer)
|
|
|
if (pending.length === 0) return
|
|
|
for (const item of pending) {
|
|
|
if (!item.file) continue
|
|
|
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()}`, {
|
|
|
method: 'POST',
|
|
|
headers: { Authorization: `Bearer ${token}`, Accept: 'application/json' },
|
|
|
body: fd,
|
|
|
})
|
|
|
const data = (await r.json().catch(() => ({}))) as Record<string, unknown>
|
|
|
if (r.status === 401) {
|
|
|
goLogin()
|
|
|
return
|
|
|
}
|
|
|
if (!r.ok) {
|
|
|
showNotice(formatApiErrSafe(data), '提示', 'warning')
|
|
|
return
|
|
|
}
|
|
|
const blobUrl = item.previewUrl
|
|
|
item.id = data.id as number
|
|
|
item.fromServer = true
|
|
|
item.original_name = data.original_name as string
|
|
|
item.size = data.size as number
|
|
|
const fileUrl = normalizePublicAssetUrl(String(data.url ?? ''))
|
|
|
item.url = fileUrl
|
|
|
item.previewUrl = fileUrl
|
|
|
if (blobUrl && String(blobUrl).startsWith('blob:')) URL.revokeObjectURL(blobUrl)
|
|
|
delete item.file
|
|
|
}
|
|
|
showNotice('文件已上传。', '上传成功', 'success')
|
|
|
if (target === 'plan') validatePlanFile()
|
|
|
else validateSupportingFiles()
|
|
|
}
|
|
|
|
|
|
async function removeFile(id: string | number, target: 'plan' | 'supporting') {
|
|
|
let items = target === 'plan' ? [...planFileItems.value] : [...supportingFileItems.value]
|
|
|
const item = items.find((e) => String(e.id) === String(id))
|
|
|
if (!item) return
|
|
|
if (item.fromServer && typeof item.id === 'number') {
|
|
|
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')
|
|
|
return
|
|
|
}
|
|
|
}
|
|
|
if (item.previewUrl && String(item.previewUrl).startsWith('blob:')) {
|
|
|
URL.revokeObjectURL(item.previewUrl as string)
|
|
|
}
|
|
|
items = items.filter((e) => String(e.id) !== String(id))
|
|
|
if (target === 'plan') planFileItems.value = items
|
|
|
else supportingFileItems.value = items
|
|
|
wasValidated.value = true
|
|
|
if (target === 'plan') validatePlanFile()
|
|
|
else validateSupportingFiles()
|
|
|
}
|
|
|
|
|
|
function addFiles(fileList: FileList | null, target: 'plan' | 'supporting') {
|
|
|
if (!fileList?.length) return
|
|
|
const items = target === 'plan' ? planFileItems.value : supportingFileItems.value
|
|
|
Array.from(fileList).forEach((file) => {
|
|
|
items.push({
|
|
|
id: `tmp-${Date.now()}-${Math.random().toString(16).slice(2)}`,
|
|
|
file,
|
|
|
previewUrl: URL.createObjectURL(file),
|
|
|
fromServer: false,
|
|
|
})
|
|
|
})
|
|
|
if (target === 'plan') {
|
|
|
planFileItems.value = [...items]
|
|
|
planFileSavedInfo.value = ''
|
|
|
const p = templateRefEl(planFileInput)
|
|
|
if (p) p.value = ''
|
|
|
wasValidated.value = true
|
|
|
validatePlanFile()
|
|
|
} else {
|
|
|
supportingFileItems.value = [...items]
|
|
|
supportingFilesSavedInfo.value = ''
|
|
|
const s = templateRefEl(supportingFileInput)
|
|
|
if (s) s.value = ''
|
|
|
wasValidated.value = true
|
|
|
validateSupportingFiles()
|
|
|
}
|
|
|
void uploadNewFiles(target)
|
|
|
}
|
|
|
|
|
|
function getDraftData() {
|
|
|
return {
|
|
|
formModel: { ...formModel },
|
|
|
planFileNames: planFileItems.value.map(fileItemDisplayName).filter(Boolean),
|
|
|
supportingFileNames: supportingFileItems.value.map(fileItemDisplayName).filter(Boolean),
|
|
|
}
|
|
|
}
|
|
|
|
|
|
function saveDraftLocal() {
|
|
|
const draft = getDraftData()
|
|
|
localStorage.setItem(STORAGE_KEY.value, JSON.stringify(draft))
|
|
|
if (draft.planFileNames.length) {
|
|
|
planFileSavedInfo.value = `已保存商业计划书名称:${draft.planFileNames.join('、')}`
|
|
|
} else planFileSavedInfo.value = ''
|
|
|
if (draft.supportingFileNames.length) {
|
|
|
supportingFilesSavedInfo.value = `已保存其他佐证材料名称:${draft.supportingFileNames.join('、')}`
|
|
|
} else supportingFilesSavedInfo.value = ''
|
|
|
}
|
|
|
|
|
|
function restoreDraft() {
|
|
|
const raw = localStorage.getItem(STORAGE_KEY.value)
|
|
|
if (!raw) return
|
|
|
try {
|
|
|
const draft = JSON.parse(raw) as { formModel?: Record<string, string>; planFileNames?: string[]; supportingFileNames?: string[] }
|
|
|
if (draft.formModel && typeof draft.formModel === 'object') {
|
|
|
for (const k of Object.keys(draft.formModel)) {
|
|
|
formModel[k] = draft.formModel[k] ?? ''
|
|
|
}
|
|
|
}
|
|
|
const restoredPlan = draft.planFileNames || []
|
|
|
if (restoredPlan.length) {
|
|
|
planFileSavedInfo.value = `上次保存商业计划书名称:${restoredPlan.join('、')}(浏览器限制,需重新选择后预览或删除)`
|
|
|
}
|
|
|
const sup = draft.supportingFileNames
|
|
|
if (sup?.length) {
|
|
|
supportingFilesSavedInfo.value = `上次保存其他佐证材料名称:${sup.join('、')}(浏览器限制,需重新选择后预览或删除)`
|
|
|
}
|
|
|
syncLocationFields()
|
|
|
} catch {
|
|
|
localStorage.removeItem(STORAGE_KEY.value)
|
|
|
}
|
|
|
}
|
|
|
|
|
|
async function loadPublicCompetition() {
|
|
|
competitionHydrated.value = false
|
|
|
const s = slug.value
|
|
|
if (!s) {
|
|
|
loadError.value = '缺少赛事地址'
|
|
|
return
|
|
|
}
|
|
|
loadError.value = ''
|
|
|
try {
|
|
|
const r = await fetch(`${apiBase()}/api/v1/public/competitions/by-slug/${encodeURIComponent(s)}`, {
|
|
|
headers: { Accept: 'application/json' },
|
|
|
})
|
|
|
if (!r.ok) {
|
|
|
loadError.value = r.status === 404 ? '赛事不存在或未发布' : '无法加载赛事配置'
|
|
|
return
|
|
|
}
|
|
|
const raw = (await r.json()) as Record<string, unknown>
|
|
|
const data =
|
|
|
raw.data != null && typeof raw.data === 'object' && !Array.isArray(raw.data)
|
|
|
? (raw.data as Record<string, unknown>)
|
|
|
: raw
|
|
|
|
|
|
competitionName.value = String(data.name ?? '')
|
|
|
pledgeContentHtml.value =
|
|
|
typeof data.pledge_content_html === 'string' ? data.pledge_content_html : ''
|
|
|
const tr = data.tracks
|
|
|
competitionTracks.value = Array.isArray(tr) ? (tr as PublicTrackRow[]) : []
|
|
|
|
|
|
let schemaRaw: unknown = data.signup_form_schema
|
|
|
if (typeof schemaRaw === 'string') {
|
|
|
try {
|
|
|
schemaRaw = JSON.parse(schemaRaw) as unknown
|
|
|
} catch {
|
|
|
schemaRaw = []
|
|
|
}
|
|
|
}
|
|
|
schemaFields.value = normalizeSignupSchema(schemaRaw)
|
|
|
ensureFormKeys(schemaFields.value)
|
|
|
brand.value = brandingFormFromApi(data.branding_json ?? null)
|
|
|
const dt = brand.value.documentTitle
|
|
|
if (hasVisibleText(dt) && typeof document !== 'undefined') {
|
|
|
document.title = dt.trim()
|
|
|
}
|
|
|
competitionHydrated.value = true
|
|
|
} catch {
|
|
|
loadError.value = '网络错误,无法加载赛事'
|
|
|
}
|
|
|
}
|
|
|
|
|
|
function applyServerPayload(d: {
|
|
|
status?: string
|
|
|
participant_may_edit?: boolean
|
|
|
player_name?: string
|
|
|
school?: string
|
|
|
degree?: string
|
|
|
contact_email?: string
|
|
|
contact_mobile?: string
|
|
|
company_name?: string
|
|
|
project_name?: string
|
|
|
track?: string
|
|
|
location_country?: string
|
|
|
location_province?: string
|
|
|
location_city?: string
|
|
|
oversea_country?: string
|
|
|
intro?: string
|
|
|
promise_signed_at?: string | null
|
|
|
promise_signature?: string | null
|
|
|
files?: { id: number; kind: string; original_name: string; size: number; url: string }[]
|
|
|
}) {
|
|
|
applicationStatus.value = (d.status as 'draft' | 'submitted') || 'draft'
|
|
|
participantMayEdit.value = d.participant_may_edit !== false
|
|
|
formModel.player_name = d.player_name || ''
|
|
|
formModel.school = d.school || ''
|
|
|
formModel.degree = d.degree || ''
|
|
|
formModel.contact_email = d.contact_email || ''
|
|
|
formModel.contact_mobile = d.contact_mobile || ''
|
|
|
formModel.company_name = d.company_name || ''
|
|
|
formModel.project_name = d.project_name || ''
|
|
|
formModel.track = d.track || ''
|
|
|
formModel.location_country = d.location_country || ''
|
|
|
formModel.location_province = d.location_province || ''
|
|
|
formModel.location_city = d.location_city || ''
|
|
|
formModel.oversea_country = d.oversea_country || ''
|
|
|
formModel.intro = d.intro || ''
|
|
|
const sig = typeof d.promise_signature === 'string' ? d.promise_signature : ''
|
|
|
formModel.promise_signature = sig
|
|
|
formModel.commitment_accepted = sig.trim() && d.promise_signed_at ? '1' : ''
|
|
|
syncLocationFields()
|
|
|
planFileItems.value = (d.files || [])
|
|
|
.filter((f) => f.kind === 'plan')
|
|
|
.map((f) => {
|
|
|
const u = normalizePublicAssetUrl(f.url)
|
|
|
return {
|
|
|
id: f.id,
|
|
|
fromServer: true,
|
|
|
original_name: f.original_name,
|
|
|
size: f.size,
|
|
|
url: u,
|
|
|
previewUrl: u,
|
|
|
}
|
|
|
})
|
|
|
supportingFileItems.value = (d.files || [])
|
|
|
.filter((f) => f.kind === 'supporting')
|
|
|
.map((f) => {
|
|
|
const u = normalizePublicAssetUrl(f.url)
|
|
|
return {
|
|
|
id: f.id,
|
|
|
fromServer: true,
|
|
|
original_name: f.original_name,
|
|
|
size: f.size,
|
|
|
url: u,
|
|
|
previewUrl: u,
|
|
|
}
|
|
|
})
|
|
|
validatePlanFile()
|
|
|
validateSupportingFiles()
|
|
|
}
|
|
|
|
|
|
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()
|
|
|
return false
|
|
|
}
|
|
|
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
|
|
|
}
|
|
|
try {
|
|
|
const d = (await r.json()) as Parameters<typeof applyServerPayload>[0]
|
|
|
applyServerPayload(d)
|
|
|
} catch {
|
|
|
/* 非 JSON 时保持本地状态 */
|
|
|
}
|
|
|
return true
|
|
|
}
|
|
|
|
|
|
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) {
|
|
|
void r.json().catch(() => ({}))
|
|
|
showNotice('报名未能提交,请检查网络或稍后重试。', '提交失败', 'warning')
|
|
|
return false
|
|
|
}
|
|
|
const d = (await r.json()) as Parameters<typeof applyServerPayload>[0]
|
|
|
applyServerPayload(d)
|
|
|
return true
|
|
|
}
|
|
|
|
|
|
async function onSaveClick() {
|
|
|
if (!localStorage.getItem(TOKEN_KEY)) {
|
|
|
goLogin()
|
|
|
return
|
|
|
}
|
|
|
const ok = await saveDraftToServer()
|
|
|
if (!ok) return
|
|
|
saveDraftLocal()
|
|
|
showNotice('已保存草稿(按当前已填写信息暂存)。', '保存成功', 'success')
|
|
|
}
|
|
|
|
|
|
async function onSubmitClick() {
|
|
|
if (!localStorage.getItem(TOKEN_KEY)) {
|
|
|
goLogin()
|
|
|
return
|
|
|
}
|
|
|
if (!validateForm()) return
|
|
|
const ok = await submitApplicationToServer()
|
|
|
if (!ok) return
|
|
|
showNotice('已提交报名', '提交成功', 'success')
|
|
|
}
|
|
|
|
|
|
onBeforeUnmount(() => {
|
|
|
promiseModalBootstrapCleanup?.()
|
|
|
teardownPromiseSigListeners()
|
|
|
})
|
|
|
|
|
|
onMounted(() => {
|
|
|
void (async () => {
|
|
|
await loadPublicCompetition()
|
|
|
if (loadError.value) return
|
|
|
const loaded = await loadApplicationFromServer()
|
|
|
if (!loaded) restoreDraft()
|
|
|
syncLocationFields()
|
|
|
})()
|
|
|
})
|
|
|
</script>
|
|
|
|
|
|
<template>
|
|
|
<div class="apply-form-page">
|
|
|
<div class="form-page-header flex-shrink-0 mb-3">
|
|
|
<h5 class="mb-0 form-page-title">{{ formPageTitle }}</h5>
|
|
|
<p v-if="!loadError && applyPageSubtitle" class="text-secondary prototype-subtitle mb-0 mt-2">{{ applyPageSubtitle }}</p>
|
|
|
<p v-if="loadError" class="text-danger small mb-0 mt-2">{{ loadError }}</p>
|
|
|
<p v-else-if="competitionHydrated && competitionTracks.length === 0 && schemaFields.some((f) => f.key === 'track')" class="text-warning small mb-0 mt-2">
|
|
|
本场赛事尚未在「赛道管理」中配置可用赛道,提交时可能会失败,请联系管理员。
|
|
|
</p>
|
|
|
</div>
|
|
|
|
|
|
<div v-if="!loadError" class="apply-form-scroll">
|
|
|
<div class="card mb-0">
|
|
|
<div class="card-body">
|
|
|
<form
|
|
|
id="applyForm"
|
|
|
ref="applyFormEl"
|
|
|
class="row g-3"
|
|
|
:class="{ 'was-validated': wasValidated }"
|
|
|
@submit.prevent
|
|
|
>
|
|
|
<template v-for="field in orderedSignupFields" :key="field.type === 'file' ? 'f-' + field.key : field.key">
|
|
|
<div v-if="field.key === 'track'" class="col-md-4">
|
|
|
<label class="form-label" id="trackFieldLabel"
|
|
|
>{{ field.label }} <span v-if="effectiveRequired(field)" class="text-danger">*</span></label
|
|
|
>
|
|
|
<div class="track-custom-select w-100">
|
|
|
<select
|
|
|
id="track"
|
|
|
ref="trackHiddenSelect"
|
|
|
v-model="formModel.track"
|
|
|
class="visually-hidden"
|
|
|
:required="effectiveRequired(field)"
|
|
|
tabindex="-1"
|
|
|
aria-hidden="true"
|
|
|
:disabled="formDisabled"
|
|
|
>
|
|
|
<option value="">请选择</option>
|
|
|
<option v-for="t in competitionTracks" :key="t.track_code" :value="t.track_code">
|
|
|
{{ t.title }}
|
|
|
</option>
|
|
|
</select>
|
|
|
<div class="dropdown w-100">
|
|
|
<button
|
|
|
ref="trackDropdownBtn"
|
|
|
type="button"
|
|
|
class="dropdown-toggle form-select track-custom-toggle editable w-100 text-start"
|
|
|
aria-labelledby="trackFieldLabel"
|
|
|
aria-expanded="false"
|
|
|
data-bs-toggle="dropdown"
|
|
|
data-bs-display="static"
|
|
|
data-bs-auto-close="true"
|
|
|
:disabled="formDisabled"
|
|
|
:class="{ 'is-invalid': trackInvalid && wasValidated }"
|
|
|
>
|
|
|
<span class="track-toggle-label text-truncate">
|
|
|
<span :class="trackMainClass">{{ trackMainText }}</span>
|
|
|
<span v-if="trackNoteText" class="track-custom-note">{{ trackNoteText }}</span>
|
|
|
</span>
|
|
|
</button>
|
|
|
<ul class="dropdown-menu w-100 border shadow-sm rounded-2 py-0 my-1 track-custom-menu">
|
|
|
<li>
|
|
|
<button
|
|
|
type="button"
|
|
|
class="dropdown-item track-pick border-0 w-100 text-start rounded-0"
|
|
|
@click="pickTrack('')"
|
|
|
>
|
|
|
<span class="text-secondary">请选择</span>
|
|
|
</button>
|
|
|
</li>
|
|
|
<li v-for="t in competitionTracks" :key="t.track_code">
|
|
|
<button
|
|
|
type="button"
|
|
|
class="dropdown-item track-pick border-0 w-100 text-start rounded-0"
|
|
|
@click="pickTrack(t.track_code)"
|
|
|
>
|
|
|
<span class="track-pick-inline">
|
|
|
<span class="text-body">{{ t.title }}</span>
|
|
|
<span v-if="trackPickDescription(t.description)" class="track-custom-note">{{
|
|
|
trackPickDescription(t.description)
|
|
|
}}</span>
|
|
|
</span>
|
|
|
</button>
|
|
|
</li>
|
|
|
</ul>
|
|
|
<div class="invalid-feedback">请选择主题赛道</div>
|
|
|
</div>
|
|
|
</div>
|
|
|
<small
|
|
|
v-if="participantFieldHelp(field.help)"
|
|
|
class="text-secondary prototype-subtitle d-block"
|
|
|
>{{ participantFieldHelp(field.help) }}</small
|
|
|
>
|
|
|
</div>
|
|
|
|
|
|
<div v-else-if="field.type === 'file' && field.key === 'plan'" class="col-12">
|
|
|
<label class="form-label"
|
|
|
>{{ field.label }} <span v-if="field.required" class="text-danger">*</span></label
|
|
|
>
|
|
|
<input
|
|
|
ref="planFileInput"
|
|
|
type="file"
|
|
|
class="form-control editable"
|
|
|
:class="{ 'is-invalid': wasValidated && !!planFileFeedback }"
|
|
|
accept=".pdf,.ppt,.pptx,.doc,.docx,.wps,.rar,.zip"
|
|
|
multiple
|
|
|
:disabled="formDisabled"
|
|
|
@change="addFiles(($event.target as HTMLInputElement).files, 'plan')"
|
|
|
/>
|
|
|
<template v-if="participantFieldHelp(field.help)">
|
|
|
<small class="text-secondary prototype-subtitle d-block mt-1">{{
|
|
|
participantFieldHelp(field.help)
|
|
|
}}</small>
|
|
|
</template>
|
|
|
<div v-if="planFileItems.length" class="supporting-file-list mt-2" aria-live="polite">
|
|
|
<div
|
|
|
v-for="item in planFileItems"
|
|
|
:key="String(item.id)"
|
|
|
class="supporting-file-item"
|
|
|
>
|
|
|
<div class="supporting-file-meta">
|
|
|
<span class="supporting-file-name" :title="fileItemDisplayName(item)">{{
|
|
|
fileItemDisplayName(item)
|
|
|
}}</span>
|
|
|
<span class="supporting-file-size">{{
|
|
|
formatFileSize(item.file ? item.file.size : item.size || 0)
|
|
|
}}</span>
|
|
|
</div>
|
|
|
<div class="supporting-file-actions">
|
|
|
<a
|
|
|
class="btn btn-sm btn-outline-primary"
|
|
|
:href="item.previewUrl || item.url || '#'"
|
|
|
target="_blank"
|
|
|
rel="noopener"
|
|
|
>预览</a
|
|
|
>
|
|
|
<button
|
|
|
type="button"
|
|
|
class="btn btn-sm btn-outline-secondary"
|
|
|
:disabled="formDisabled"
|
|
|
@click="removeFile(item.id, 'plan')"
|
|
|
>
|
|
|
删除
|
|
|
</button>
|
|
|
</div>
|
|
|
</div>
|
|
|
</div>
|
|
|
<small class="text-secondary prototype-subtitle d-block">{{ planFileSavedInfo }}</small>
|
|
|
<div v-if="wasValidated && planFileFeedback" class="invalid-feedback d-block">{{ planFileFeedback }}</div>
|
|
|
</div>
|
|
|
|
|
|
<div v-else-if="field.type === 'file' && field.key === 'supporting'" class="col-12">
|
|
|
<label class="form-label">{{ field.label }}</label>
|
|
|
<input
|
|
|
ref="supportingFileInput"
|
|
|
type="file"
|
|
|
class="form-control editable"
|
|
|
:class="{ 'is-invalid': wasValidated && !!supportingFilesFeedback }"
|
|
|
accept=".pdf,.ppt,.pptx,.doc,.docx,.wps,.rar,.zip"
|
|
|
multiple
|
|
|
:disabled="formDisabled"
|
|
|
@change="addFiles(($event.target as HTMLInputElement).files, 'supporting')"
|
|
|
/>
|
|
|
<template v-if="participantFieldHelp(field.help)">
|
|
|
<small class="text-secondary prototype-subtitle d-block mt-1">{{
|
|
|
participantFieldHelp(field.help)
|
|
|
}}</small>
|
|
|
</template>
|
|
|
<div v-if="supportingFileItems.length" class="supporting-file-list mt-2" aria-live="polite">
|
|
|
<div
|
|
|
v-for="item in supportingFileItems"
|
|
|
:key="String(item.id)"
|
|
|
class="supporting-file-item"
|
|
|
>
|
|
|
<div class="supporting-file-meta">
|
|
|
<span class="supporting-file-name" :title="fileItemDisplayName(item)">{{
|
|
|
fileItemDisplayName(item)
|
|
|
}}</span>
|
|
|
<span class="supporting-file-size">{{
|
|
|
formatFileSize(item.file ? item.file.size : item.size || 0)
|
|
|
}}</span>
|
|
|
</div>
|
|
|
<div class="supporting-file-actions">
|
|
|
<a
|
|
|
class="btn btn-sm btn-outline-primary"
|
|
|
:href="item.previewUrl || item.url || '#'"
|
|
|
target="_blank"
|
|
|
rel="noopener"
|
|
|
>预览</a
|
|
|
>
|
|
|
<button
|
|
|
type="button"
|
|
|
class="btn btn-sm btn-outline-secondary"
|
|
|
:disabled="formDisabled"
|
|
|
@click="removeFile(item.id, 'supporting')"
|
|
|
>
|
|
|
删除
|
|
|
</button>
|
|
|
</div>
|
|
|
</div>
|
|
|
</div>
|
|
|
<small class="text-secondary prototype-subtitle d-block">{{ supportingFilesSavedInfo }}</small>
|
|
|
<div v-if="wasValidated && supportingFilesFeedback" class="invalid-feedback d-block">{{
|
|
|
supportingFilesFeedback
|
|
|
}}</div>
|
|
|
</div>
|
|
|
|
|
|
<div v-else-if="field.type === 'select' && !isCitySelectField(field)" :class="fieldColClass(field)">
|
|
|
<label class="form-label"
|
|
|
>{{ field.label }} <span v-if="effectiveRequired(field)" class="text-danger">*</span></label
|
|
|
>
|
|
|
<select
|
|
|
v-model="formModel[field.key]"
|
|
|
class="form-select editable"
|
|
|
:required="effectiveRequired(field)"
|
|
|
:disabled="formDisabled"
|
|
|
>
|
|
|
<option value="">请选择</option>
|
|
|
<option v-for="opt in optionsForField(field)" :key="`${field.key}-${opt.value}`" :value="opt.value">
|
|
|
{{ opt.label }}
|
|
|
</option>
|
|
|
</select>
|
|
|
<div class="invalid-feedback">请选择{{ field.label }}</div>
|
|
|
<small
|
|
|
v-if="participantFieldHelp(field.help)"
|
|
|
class="text-secondary prototype-subtitle d-block"
|
|
|
>{{ participantFieldHelp(field.help) }}</small
|
|
|
>
|
|
|
</div>
|
|
|
|
|
|
<div v-else-if="isCitySelectField(field)" :class="fieldColClass(field)">
|
|
|
<label class="form-label"
|
|
|
>{{ field.label }} <span v-if="effectiveRequired(field)" class="text-danger">*</span></label
|
|
|
>
|
|
|
<select
|
|
|
v-model="formModel.location_city"
|
|
|
class="form-select editable"
|
|
|
:required="effectiveRequired(field)"
|
|
|
:disabled="formDisabled || !isChina"
|
|
|
>
|
|
|
<option value="">请选择</option>
|
|
|
<option v-for="opt in optionsForField(field)" :key="opt.value" :value="opt.value">
|
|
|
{{ opt.label }}
|
|
|
</option>
|
|
|
</select>
|
|
|
<div class="invalid-feedback">请选择{{ field.label }}</div>
|
|
|
<small
|
|
|
v-if="participantFieldHelp(field.help)"
|
|
|
class="text-secondary prototype-subtitle d-block"
|
|
|
>{{ participantFieldHelp(field.help) }}</small
|
|
|
>
|
|
|
</div>
|
|
|
|
|
|
<div
|
|
|
v-else-if="field.type === 'checkbox' && field.key === 'commitment_accepted'"
|
|
|
:class="fieldColClass(field)"
|
|
|
>
|
|
|
<label class="form-label"
|
|
|
>{{ field.label }} <span v-if="effectiveRequired(field)" class="text-danger">*</span></label
|
|
|
>
|
|
|
<p class="small text-secondary mb-2">
|
|
|
请点击按钮打开承诺书全文,阅读后在文末手写签名并点击「确认签署」。
|
|
|
</p>
|
|
|
<div class="d-flex flex-wrap align-items-center gap-2 mb-1">
|
|
|
<button
|
|
|
type="button"
|
|
|
class="btn btn-outline-primary btn-sm editable"
|
|
|
:disabled="formDisabled"
|
|
|
@click="openPromiseSignModal"
|
|
|
>
|
|
|
{{ commitmentSigned ? '重新查看承诺书' : '查看并签署参赛承诺书' }}
|
|
|
</button>
|
|
|
<span v-if="commitmentSigned" class="badge rounded-pill px-3 py-2 promise-signed-badge"
|
|
|
>已完成签署</span
|
|
|
>
|
|
|
</div>
|
|
|
<small
|
|
|
v-if="participantFieldHelp(field.help)"
|
|
|
class="text-secondary prototype-subtitle d-block mt-1"
|
|
|
>{{ participantFieldHelp(field.help) }}</small
|
|
|
>
|
|
|
</div>
|
|
|
|
|
|
<div v-else-if="field.type === 'checkbox'" :class="fieldColClass(field)">
|
|
|
<div class="form-check">
|
|
|
<input
|
|
|
:id="'signup-field-' + field.key"
|
|
|
v-model="formModel[field.key]"
|
|
|
type="checkbox"
|
|
|
class="form-check-input editable"
|
|
|
true-value="1"
|
|
|
false-value=""
|
|
|
:required="effectiveRequired(field)"
|
|
|
:disabled="formDisabled"
|
|
|
/>
|
|
|
<label class="form-check-label" :for="'signup-field-' + field.key">
|
|
|
{{ field.label }}<span v-if="effectiveRequired(field)" class="text-danger"> *</span>
|
|
|
</label>
|
|
|
<div class="invalid-feedback">请勾选此项以继续</div>
|
|
|
</div>
|
|
|
<small
|
|
|
v-if="participantFieldHelp(field.help)"
|
|
|
class="text-secondary prototype-subtitle d-block mt-1"
|
|
|
>{{ participantFieldHelp(field.help) }}</small
|
|
|
>
|
|
|
</div>
|
|
|
|
|
|
<div v-else-if="field.type === 'textarea'" :class="fieldColClass(field)">
|
|
|
<label class="form-label"
|
|
|
>{{ field.label }} <span v-if="effectiveRequired(field)" class="text-danger">*</span></label
|
|
|
>
|
|
|
<textarea
|
|
|
v-model="formModel[field.key]"
|
|
|
class="form-control editable"
|
|
|
rows="4"
|
|
|
placeholder=""
|
|
|
:required="effectiveRequired(field)"
|
|
|
:disabled="formDisabled"
|
|
|
/>
|
|
|
<div
|
|
|
v-if="textareaFieldPlaceholder(field) || field.key === 'intro'"
|
|
|
class="d-flex flex-wrap align-items-baseline gap-2 mt-1 w-100"
|
|
|
>
|
|
|
<small v-if="textareaFieldPlaceholder(field)" class="apply-textarea-placeholder-hint mb-0">{{
|
|
|
textareaFieldPlaceholder(field)
|
|
|
}}</small>
|
|
|
<small
|
|
|
v-if="field.key === 'intro'"
|
|
|
class="text-secondary prototype-subtitle intro-char-count mb-0 ms-auto"
|
|
|
>{{ introCount }} 字</small
|
|
|
>
|
|
|
</div>
|
|
|
<small
|
|
|
v-if="participantFieldHelp(field.help)"
|
|
|
class="text-secondary prototype-subtitle d-block"
|
|
|
:class="{ 'mt-1': field.key === 'intro' || textareaFieldPlaceholder(field) }"
|
|
|
>{{ participantFieldHelp(field.help) }}</small
|
|
|
>
|
|
|
</div>
|
|
|
|
|
|
<div v-else :class="fieldColClass(field)">
|
|
|
<label class="form-label"
|
|
|
>{{ field.label }} <span v-if="effectiveRequired(field)" class="text-danger">*</span></label
|
|
|
>
|
|
|
<input
|
|
|
v-model="formModel[field.key]"
|
|
|
:type="
|
|
|
field.type === 'email'
|
|
|
? 'email'
|
|
|
: field.key === 'contact_mobile'
|
|
|
? 'tel'
|
|
|
: 'text'
|
|
|
"
|
|
|
class="form-control editable"
|
|
|
:class="{ 'company-name-input': field.key === 'company_name' }"
|
|
|
:required="effectiveRequired(field)"
|
|
|
:disabled="formDisabled"
|
|
|
:placeholder="(field.placeholder ?? '').trim()"
|
|
|
:pattern="field.key === 'contact_mobile' ? MOBILE_PATTERN : undefined"
|
|
|
:autocomplete="field.key === 'company_name' ? 'organization' : undefined"
|
|
|
/>
|
|
|
<div class="invalid-feedback">{{ nativeInputInvalidFeedback(field) }}</div>
|
|
|
<small
|
|
|
v-if="participantFieldHelp(field.help)"
|
|
|
class="text-secondary prototype-subtitle d-block"
|
|
|
>{{ participantFieldHelp(field.help) }}</small
|
|
|
>
|
|
|
</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">保存</button>
|
|
|
<button type="button" class="btn btn-success" :disabled="formDisabled" @click="onSubmitClick">提交报名</button>
|
|
|
</div>
|
|
|
</form>
|
|
|
</div>
|
|
|
</div>
|
|
|
</div>
|
|
|
|
|
|
<div
|
|
|
v-if="!loadError && schemaFields.some((f) => f.key === 'commitment_accepted')"
|
|
|
id="promiseSignModal"
|
|
|
ref="promiseSignModalRef"
|
|
|
class="modal fade"
|
|
|
tabindex="-1"
|
|
|
aria-hidden="true"
|
|
|
data-bs-backdrop="static"
|
|
|
>
|
|
|
<div class="modal-dialog modal-dialog-centered promise-sign-modal">
|
|
|
<div class="modal-content promise-sign-sheet">
|
|
|
<div class="modal-body pt-2">
|
|
|
<div class="promise-doc-paper">
|
|
|
<h1 class="promise-doc-heading text-center">{{ promiseModalHeading }}</h1>
|
|
|
<div class="promise-doc-scroll">
|
|
|
<div class="promise-doc-body promise-doc-body--rich" v-html="pledgeBodyDisplayHtml"></div>
|
|
|
</div>
|
|
|
<div class="promise-doc-signblock">
|
|
|
<div class="promise-doc-signrow">
|
|
|
<span class="promise-doc-signlabel">参赛人签名:</span>
|
|
|
<div class="promise-sig-wrap">
|
|
|
<div class="promise-sig-box">
|
|
|
<canvas
|
|
|
ref="promiseCanvasRef"
|
|
|
class="promise-sig-canvas"
|
|
|
width="520"
|
|
|
height="140"
|
|
|
aria-label="手写签名区域"
|
|
|
/>
|
|
|
<span class="promise-sig-hint" :class="{ 'd-none': !promiseSigHintVisible }"
|
|
|
>请在框内手写签名</span
|
|
|
>
|
|
|
</div>
|
|
|
<div class="promise-sig-tools">
|
|
|
<button type="button" class="btn btn-sm btn-outline-secondary" @click="clearPromiseSignatureCanvas">
|
|
|
清除签名
|
|
|
</button>
|
|
|
</div>
|
|
|
</div>
|
|
|
</div>
|
|
|
<div class="promise-doc-daterow">
|
|
|
<span class="promise-doc-dateline"
|
|
|
>日期:<strong>{{ promiseModalDateText }}</strong></span
|
|
|
>
|
|
|
</div>
|
|
|
</div>
|
|
|
<div class="promise-sign-actions">
|
|
|
<button type="button" class="btn promise-close-btn" data-bs-dismiss="modal">关闭</button>
|
|
|
<button type="button" class="btn btn-primary" @click="confirmPromiseSignature">确认签署</button>
|
|
|
</div>
|
|
|
</div>
|
|
|
</div>
|
|
|
</div>
|
|
|
</div>
|
|
|
</div>
|
|
|
|
|
|
<div ref="noticeModalEl" id="applyNoticeModal" class="modal fade" tabindex="-1" aria-hidden="true">
|
|
|
<div class="modal-dialog modal-dialog-centered notice-modal-dialog">
|
|
|
<div
|
|
|
class="modal-content notice-modal-content"
|
|
|
:class="noticeIsSuccess ? 'notice-success' : 'notice-warning'"
|
|
|
>
|
|
|
<div class="modal-header border-0 pb-0">
|
|
|
<button type="button" class="btn-close ms-auto" data-bs-dismiss="modal" aria-label="关闭" />
|
|
|
</div>
|
|
|
<div class="modal-body notice-modal-body">
|
|
|
<div class="notice-icon" aria-hidden="true">
|
|
|
<svg
|
|
|
v-if="noticeIsSuccess"
|
|
|
viewBox="0 0 24 24"
|
|
|
width="36"
|
|
|
height="36"
|
|
|
fill="none"
|
|
|
aria-hidden="true"
|
|
|
>
|
|
|
<circle cx="12" cy="12" r="10" stroke="currentColor" stroke-width="1.8" />
|
|
|
<path
|
|
|
d="M7 12.2l3.1 3.1L17.2 8.8"
|
|
|
stroke="currentColor"
|
|
|
stroke-width="2"
|
|
|
stroke-linecap="round"
|
|
|
stroke-linejoin="round"
|
|
|
/>
|
|
|
</svg>
|
|
|
<svg v-else viewBox="0 0 24 24" width="36" height="36" fill="none" aria-hidden="true">
|
|
|
<path
|
|
|
d="M12 3.6L2.9 19.2a1 1 0 0 0 .86 1.5h16.48a1 1 0 0 0 .86-1.5L12 3.6z"
|
|
|
stroke="currentColor"
|
|
|
stroke-width="1.8"
|
|
|
/>
|
|
|
<path d="M12 9v5" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
|
|
|
<circle cx="12" cy="16.8" r="1.1" fill="currentColor" />
|
|
|
</svg>
|
|
|
</div>
|
|
|
<h5 class="notice-title">{{ noticeTitle }}</h5>
|
|
|
<p v-if="noticeBody.trim()" class="notice-text text-break" style="white-space: pre-wrap">
|
|
|
{{ noticeBody }}
|
|
|
</p>
|
|
|
</div>
|
|
|
<div class="modal-footer notice-modal-footer border-0">
|
|
|
<button type="button" class="btn btn-light notice-cancel-btn" data-bs-dismiss="modal">取消</button>
|
|
|
<button type="button" class="btn btn-primary notice-ok-btn" data-bs-dismiss="modal">确定</button>
|
|
|
</div>
|
|
|
</div>
|
|
|
</div>
|
|
|
</div>
|
|
|
</div>
|
|
|
</template>
|
|
|
|
|
|
<style scoped>
|
|
|
.promise-doc-body--rich {
|
|
|
font-size: 0.95rem;
|
|
|
line-height: 1.65;
|
|
|
color: var(--bs-body-color, #212529);
|
|
|
text-align: justify;
|
|
|
}
|
|
|
|
|
|
.promise-doc-body--rich :deep(p) {
|
|
|
margin-bottom: 0.75rem;
|
|
|
}
|
|
|
|
|
|
.promise-doc-body--rich :deep(p:last-child) {
|
|
|
margin-bottom: 0;
|
|
|
}
|
|
|
|
|
|
.promise-doc-body--rich :deep(ul),
|
|
|
.promise-doc-body--rich :deep(ol) {
|
|
|
padding-left: 1.25rem;
|
|
|
margin-bottom: 0.75rem;
|
|
|
}
|
|
|
|
|
|
.promise-doc-body--rich :deep(h1),
|
|
|
.promise-doc-body--rich :deep(h2),
|
|
|
.promise-doc-body--rich :deep(h3) {
|
|
|
font-size: 1rem;
|
|
|
font-weight: 600;
|
|
|
margin: 0.75rem 0 0.5rem;
|
|
|
}
|
|
|
</style>
|