diff --git a/src/components/admin/FormSchemaVisualEditor.vue b/src/components/admin/FormSchemaVisualEditor.vue
index bcd84d6..83e917c 100644
--- a/src/components/admin/FormSchemaVisualEditor.vue
+++ b/src/components/admin/FormSchemaVisualEditor.vue
@@ -133,6 +133,19 @@ function applyOptionsText(element: FormSchemaEditorItem, raw: string) {
填报说明
+
+ 文件格式限制(可选)
+
+
+
+ 最多上传文件数(可选)
+
+
选项(每行:显示文本|值)
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 }))
}
diff --git a/src/utils/formSchemaEditor.ts b/src/utils/formSchemaEditor.ts
index c433b17..ec80714 100644
--- a/src/utils/formSchemaEditor.ts
+++ b/src/utils/formSchemaEditor.ts
@@ -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 !== '')
}
diff --git a/src/views/ApplyFormView.vue b/src/views/ApplyFormView.vue
index a7e1975..ef6d2da 100644
--- a/src/views/ApplyFormView.vue
+++ b/src/views/ApplyFormView.vue
@@ -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(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([])
/** 赛事 /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')"