You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

1516 lines
54 KiB

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

<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.modalVue 会当成事件修饰符解析坏) */
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>