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')"