标题补充

main
lion 3 weeks ago
parent 779ed4a979
commit 7737c5d439

@ -133,6 +133,19 @@ function applyOptionsText(element: FormSchemaEditorItem, raw: string) {
<div class="field-label">填报说明</div>
<el-input v-model="element.help" placeholder="可选,展示在表单项下方" />
</el-col>
<el-col v-if="purpose === 'signup' && element.type === 'file'" :span="24">
<div class="field-label">文件格式限制可选</div>
<el-input
v-model="element.fileExtensionsLines"
type="textarea"
:rows="2"
placeholder="每行一个扩展名(不含点),如 pdf不填则不额外收紧仍仅能上传系统全局允许的格式"
/>
</el-col>
<el-col v-if="purpose === 'signup' && element.type === 'file'" :xs="24" :sm="8">
<div class="field-label">最多上传文件数可选</div>
<el-input v-model="element.fileMaxCount" placeholder="不填则不限制个数" inputmode="numeric" />
</el-col>
<el-col v-if="element.type === 'select'" :span="24">
<div class="field-label">选项每行显示文本|</div>
<el-input

@ -14,8 +14,24 @@ export interface SignupFormSchemaField {
options?: { label: string; value: string }[]
/** 当依赖字段取值命中列表时,本字段视为必填(与企业名称依赖参赛组别一致) */
required_when?: { field: string; values: string[] }
/** 报名表文件字段:允许的扩展名(不含点,小写);未配置则使用系统默认白名单 */
file_extensions?: string[]
/** 报名表文件字段:最多文件数;未配置或小于 1 表示不限制 */
file_max_count?: number
}
/** 与 Laravel `config/contest.file_mimes` 一致schema 未配置 `file_extensions` 时沿用此白名单 */
export const CONTEST_DEFAULT_FILE_EXTENSIONS: readonly string[] = [
'pdf',
'ppt',
'pptx',
'doc',
'docx',
'wps',
'rar',
'zip',
]
/**
* `tracks` schema track options
*/
@ -139,6 +155,32 @@ function parseSchemaOptionsArray(raw: unknown): { label: string; value: string }
)
}
function parseFileExtensionsFromUnknown(raw: unknown): string[] | undefined {
if (raw == null) return undefined
if (Array.isArray(raw)) {
const parts = raw
.map((x) => String(x).trim().toLowerCase().replace(/^\./, ''))
.filter((s) => s !== '')
return parts.length ? [...new Set(parts)] : undefined
}
if (typeof raw === 'string') {
const parts = raw
.split(/[,;\s|]+/)
.map((s) => s.trim().toLowerCase().replace(/^\./, ''))
.filter((s) => s !== '')
return parts.length ? [...new Set(parts)] : undefined
}
return undefined
}
function parseFileMaxCountFromUnknown(raw: unknown): number | undefined {
if (raw == null || raw === '') return undefined
const n = typeof raw === 'number' ? raw : parseInt(String(raw), 10)
if (!Number.isFinite(n)) return undefined
const i = Math.floor(n)
return i >= 1 ? i : undefined
}
export function normalizeSignupSchema(raw: unknown): SignupFormSchemaField[] {
if (!Array.isArray(raw) || raw.length === 0) {
return DEFAULT_SIGNUP_FORM_SCHEMA.map((f) => ({ ...f, options: f.options ? [...f.options] : undefined }))
@ -193,7 +235,25 @@ export function normalizeSignupSchema(raw: unknown): SignupFormSchemaField[] {
}
}
}
out.push({ key, type, label, title_supplement, required, placeholder, help, options, required_when })
let file_extensions: string[] | undefined
let file_max_count: number | undefined
if (type === 'file') {
file_extensions = parseFileExtensionsFromUnknown(o.file_extensions)
file_max_count = parseFileMaxCountFromUnknown(o.file_max_count)
}
out.push({
key,
type,
label,
title_supplement,
required,
placeholder,
help,
options,
required_when,
file_extensions,
file_max_count,
})
}
return out.length ? out : DEFAULT_SIGNUP_FORM_SCHEMA.map((f) => ({ ...f }))
}

@ -19,6 +19,10 @@ export interface FormSchemaEditorItem {
requiredWhenField?: string
/** 报名表:依赖取值,每行一个(入库为 required_when.values */
requiredWhenValuesLines?: string
/** 文件字段:每行一个扩展名不含点 */
fileExtensionsLines?: string
/** 文件字段:最多文件数,空为不限制 */
fileMaxCount?: string
}
/** 入库时为 checkbox + key commitment_accepted仅供报名表可视化 */
@ -64,6 +68,8 @@ export function createEmptySchemaItem(
options: [],
requiredWhenField: '',
requiredWhenValuesLines: '',
fileExtensionsLines: '',
fileMaxCount: '',
}
}
@ -119,6 +125,27 @@ export function schemaJsonToEditorItems(json: unknown, purpose: FormSchemaPurpos
options: type === 'select' ? normalizeOptions(o.options) : [],
requiredWhenField,
requiredWhenValuesLines,
fileExtensionsLines: (() => {
if (type !== 'file') return ''
const fe = o.file_extensions
if (!Array.isArray(fe)) return ''
const lines = fe
.map((x) =>
String(x)
.trim()
.toLowerCase()
.replace(/^\./, ''),
)
.filter(Boolean)
return lines.join('\n')
})(),
fileMaxCount: (() => {
if (type !== 'file') return ''
const mc = o.file_max_count
if (mc == null || mc === '') return ''
const n = typeof mc === 'number' ? mc : parseInt(String(mc), 10)
return Number.isFinite(n) && n >= 1 ? String(Math.floor(n)) : ''
})(),
}
})
}
@ -136,6 +163,20 @@ export function editorItemsToSchemaJson(items: FormSchemaEditorItem[], purpose:
if (item.placeholder?.trim()) row.placeholder = item.placeholder.trim()
if (item.help?.trim()) row.help = item.help.trim()
if (item.titleSupplement?.trim()) row.title_supplement = item.titleSupplement.trim()
if (item.type === 'file') {
const extLines = String(item.fileExtensionsLines ?? '')
.split('\n')
.map((s) =>
s
.trim()
.toLowerCase()
.replace(/^\./, ''),
)
.filter(Boolean)
if (extLines.length) row.file_extensions = [...new Set(extLines)]
const mc = parseInt(String(item.fileMaxCount ?? '').trim(), 10)
if (Number.isFinite(mc) && mc >= 1) row.file_max_count = mc
}
if (item.type === 'select' && item.options?.length) {
row.options = item.options.filter((o) => o.value !== '' || o.label !== '')
}

@ -22,14 +22,17 @@ import {
participantApplyPageTitle,
type BrandingForm,
} from '../utils/competitionBranding'
import { normalizeSignupSchema, type SignupFormSchemaField } from '../utils/defaultSignupFormSchema'
import {
CONTEST_DEFAULT_FILE_EXTENSIONS,
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}$'
@ -104,6 +107,46 @@ function goLogin() {
}
const schemaFields = ref<SignupFormSchemaField[]>(normalizeSignupSchema([]))
/** schema 文件字段允许的扩展名(与 config/contest.file_mimes ∩ schema.file_extensions */
function effectiveFileExtensionsForField(field: SignupFormSchemaField | undefined): string[] {
const globe = CONTEST_DEFAULT_FILE_EXTENSIONS.map((e) => e.toLowerCase())
const globSet = new Set(globe)
const raw = field?.file_extensions
if (!Array.isArray(raw) || raw.length === 0) return [...globe]
const cleaned = [
...new Set(
raw
.map((x) =>
String(x)
.trim()
.toLowerCase()
.replace(/^\./, ''),
)
.filter(Boolean)
.filter((e) => globSet.has(e)),
),
]
return cleaned.length > 0 ? cleaned : [...globe]
}
/** schema 配置了 file_max_count 时返回上限,否则不限 */
function effectiveMaxFileCountForField(field: SignupFormSchemaField | undefined): number | null {
const n = field?.file_max_count
if (typeof n !== 'number' || !Number.isFinite(n)) return null
const i = Math.floor(n)
return i >= 1 ? i : null
}
function fileFieldSchema(target: 'plan' | 'supporting'): SignupFormSchemaField | undefined {
return schemaFields.value.find((f) => f.type === 'file' && f.key === target)
}
function fileInputAcceptAttr(field: SignupFormSchemaField | undefined): string {
return effectiveFileExtensionsForField(field)
.map((e) => '.' + e)
.join(',')
}
const competitionTracks = ref<PublicTrackRow[]>([])
/** 赛事 /public API 已成功解析后为 true避免 tracks 尚未返回时出现黄色提示闪烁 */
const competitionHydrated = ref(false)
@ -569,20 +612,29 @@ function validateFileItems(
items: FileItem[],
missingMessage: string,
optional: boolean,
field?: SignupFormSchemaField,
): { ok: boolean; feedback: string } {
const allowed = effectiveFileExtensionsForField(field)
const maxCount = effectiveMaxFileCountForField(field)
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 }
}
if (maxCount !== null && items.length > maxCount) {
return {
ok: false,
feedback: `最多可上传 ${maxCount} 个文件,请删除多余文件后重试`,
}
}
const invalidExtFile = realFiles.map((i) => i.file!).find((file) => {
const ext = (file.name.split('.').pop() || '').toLowerCase()
return !allowedExt.includes(ext)
return !allowed.includes(ext)
})
if (invalidExtFile) {
return {
ok: false,
feedback: `${invalidExtFile.name}”格式不支持,请上传 PDF/PPT/PPTX/DOC/DOCX/WPS/RAR/ZIP`,
feedback: `${invalidExtFile.name}”格式不支持,请上传 ${allowed.join('、')} 格式的文件`,
}
}
const oversizedFile = realFiles.map((i) => i.file!).find((file) => file.size > maxFileSize)
@ -605,7 +657,8 @@ function validatePlanFile() {
templateRefEl(planFileInput)?.setCustomValidity('')
return true
}
const r = validateFileItems(planFileItems.value, '请上传商业计划书', false)
const planField = fileFieldSchema('plan')
const r = validateFileItems(planFileItems.value, '请上传商业计划书', false, planField)
planFileFeedback.value = r.feedback
templateRefEl(planFileInput)?.setCustomValidity(r.ok ? '' : 'missing')
return r.ok
@ -616,7 +669,8 @@ function validateSupportingFiles() {
templateRefEl(supportingFileInput)?.setCustomValidity('')
return true
}
const r = validateFileItems(supportingFileItems.value, '文件格式或大小不符合要求', true)
const supField = fileFieldSchema('supporting')
const r = validateFileItems(supportingFileItems.value, '文件格式或大小不符合要求', true, supField)
supportingFilesFeedback.value = r.feedback
templateRefEl(supportingFileInput)?.setCustomValidity(r.ok ? '' : 'bad')
return r.ok
@ -733,8 +787,20 @@ async function removeFile(id: string | number, target: 'plan' | 'supporting') {
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) => {
const field = fileFieldSchema(target)
const maxC = effectiveMaxFileCountForField(field)
const items = target === 'plan' ? [...planFileItems.value] : [...supportingFileItems.value]
let incoming = Array.from(fileList)
const room = maxC === null ? incoming.length : Math.max(0, maxC - items.length)
if (incoming.length > room && maxC !== null) {
showNotice(
`该栏最多上传 ${maxC} 个文件,已忽略多余 ${incoming.length - room} 个选择。`,
'提示',
'warning',
)
}
incoming = incoming.slice(0, room)
incoming.forEach((file) => {
items.push({
id: `tmp-${Date.now()}-${Math.random().toString(16).slice(2)}`,
file,
@ -1179,7 +1245,7 @@ onMounted(() => {
type="file"
class="form-control editable"
:class="{ 'is-invalid': wasValidated && !!planFileFeedback }"
accept=".pdf,.ppt,.pptx,.doc,.docx,.wps,.rar,.zip"
:accept="fileInputAcceptAttr(field)"
multiple
:disabled="formDisabled"
@change="addFiles(($event.target as HTMLInputElement).files, 'plan')"
@ -1242,7 +1308,7 @@ onMounted(() => {
type="file"
class="form-control editable"
:class="{ 'is-invalid': wasValidated && !!supportingFilesFeedback }"
accept=".pdf,.ppt,.pptx,.doc,.docx,.wps,.rar,.zip"
:accept="fileInputAcceptAttr(field)"
multiple
:disabled="formDisabled"
@change="addFiles(($event.target as HTMLInputElement).files, 'supporting')"

Loading…
Cancel
Save