/** 报名表 / 评审表 schema_json 可视化编辑(单字段) */ export type FormSchemaPurpose = 'signup' | 'review' export interface FormSchemaEditorItem { /** 前端拖拽用,不落库 */ __uid: string key: string type: string label: string required: boolean /** 选手端展示在标题后;必填时落在 * 之后(入库 title_supplement) */ titleSupplement?: string placeholder?: string help?: string /** select 等:{ label, value } */ options?: { label: string; value: string }[] /** 报名表:条件必填依赖字段 key(入库为 required_when.field) */ requiredWhenField?: string /** 报名表:依赖取值,每行一个(入库为 required_when.values) */ requiredWhenValuesLines?: string /** 文件字段:每行一个扩展名不含点 */ fileExtensionsLines?: string /** 文件字段:最多文件数,空为不限制 */ fileMaxCount?: string } /** 入库时为 checkbox + key commitment_accepted,仅供报名表可视化 */ export const SIGNUP_COMMITMENT_TYPE = 'commitment_promise' as const export const SIGNUP_FIELD_TYPES: { value: string; label: string }[] = [ { value: 'text', label: '单行文本' }, { value: 'email', label: '邮箱' }, { value: 'tel', label: '手机/电话' }, { value: 'textarea', label: '多行文本' }, { value: 'number', label: '数字' }, { value: 'select', label: '下拉选择' }, { value: 'checkbox', label: '勾选确认(通用)' }, { value: SIGNUP_COMMITMENT_TYPE, label: '签署承诺书' }, { value: 'file', label: '文件上传' }, { value: 'date', label: '日期' }, ] export const REVIEW_FIELD_TYPES: { value: string; label: string }[] = [ { value: 'number', label: '数字(打分维度)' }, { value: 'textarea', label: '多行文本(评语等)' }, { value: 'text', label: '单行文本' }, ] function newUid(): string { return globalThis.crypto?.randomUUID?.() ?? `f_${Date.now()}_${Math.random().toString(36).slice(2)}` } export function createEmptySchemaItem( purpose: FormSchemaPurpose, index: number, ): FormSchemaEditorItem { const type = purpose === 'review' ? 'number' : 'text' return { __uid: newUid(), key: purpose === 'review' ? `dim_${index + 1}` : `field_${index + 1}`, type, label: purpose === 'review' ? `评分维度 ${index + 1}` : `字段 ${index + 1}`, required: purpose !== 'review', titleSupplement: '', placeholder: '', help: '', options: [], requiredWhenField: '', requiredWhenValuesLines: '', fileExtensionsLines: '', fileMaxCount: '', } } function normalizeOptions(raw: unknown): { label: string; value: string }[] { if (!Array.isArray(raw)) return [] return raw .map((x) => { if (x !== null && typeof x === 'object' && 'label' in x && 'value' in x) { return { label: String((x as { label: unknown }).label), value: String((x as { value: unknown }).value) } } if (typeof x === 'string') return { label: x, value: x } return null }) .filter((x): x is { label: string; value: string } => x != null && x.label !== '') } /** 从接口 schema_json 解析为编辑器模型 */ export function schemaJsonToEditorItems(json: unknown, purpose: FormSchemaPurpose): FormSchemaEditorItem[] { if (!Array.isArray(json)) return [createEmptySchemaItem(purpose, 0)] return json.map((item, index) => { if (item === null || typeof item !== 'object') { return createEmptySchemaItem(purpose, index) } const o = item as Record const rawKey = String(o.key ?? (purpose === 'review' ? `dim_${index + 1}` : `field_${index + 1}`)) const rawType = String(o.type ?? (purpose === 'review' ? 'number' : 'text')) const type = purpose === 'signup' && rawKey === 'commitment_accepted' && rawType === 'checkbox' ? SIGNUP_COMMITMENT_TYPE : rawType let requiredWhenField = '' let requiredWhenValuesLines = '' if (purpose === 'signup') { const rw = o.required_when if (rw != null && typeof rw === 'object' && !Array.isArray(rw)) { const rwo = rw as Record requiredWhenField = String(rwo.field ?? '').trim() const vals = rwo.values if (Array.isArray(vals)) { requiredWhenValuesLines = vals.map((x) => String(x).trim()).filter(Boolean).join('\n') } } } return { __uid: newUid(), key: rawKey, type, label: String(o.label ?? `字段 ${index + 1}`), required: Boolean(o.required), titleSupplement: o.title_supplement != null ? String(o.title_supplement) : '', placeholder: o.placeholder != null ? String(o.placeholder) : '', help: o.help != null ? String(o.help) : '', 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)) : '' })(), } }) } /** 写入接口的 schema_json(数组) */ export function editorItemsToSchemaJson(items: FormSchemaEditorItem[], purpose: FormSchemaPurpose): unknown[] { return items.map((item) => { const isCommitment = item.type === SIGNUP_COMMITMENT_TYPE const row: Record = { key: isCommitment ? 'commitment_accepted' : item.key.trim(), type: isCommitment ? 'checkbox' : item.type, label: item.label.trim(), required: item.required, } 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 !== '') } if ( purpose === 'signup' && !isCommitment && item.requiredWhenField?.trim() && item.requiredWhenValuesLines?.trim() ) { const values = item.requiredWhenValuesLines .split('\n') .map((s) => s.trim()) .filter(Boolean) if (values.length) { row.required_when = { field: item.requiredWhenField.trim(), values } } } return row }) } export function validateEditorItems(items: FormSchemaEditorItem[], purpose: FormSchemaPurpose): string | null { const keys = new Set() for (const it of items) { const k = it.key.trim() if (!k) return '存在未填写「字段 key」的项(须为英文/数字/下划线,与存储键一致)' if (keys.has(k)) return `字段 key「${k}」重复` keys.add(k) if (!it.label.trim()) return `字段「${k}」缺少显示标签` if ( purpose === 'signup' && it.type !== SIGNUP_COMMITMENT_TYPE && it.requiredWhenField?.trim() && !it.requiredWhenValuesLines?.trim() ) { return `字段「${k}」填写了条件必填依赖,但未填写「当取值」列表` } } return null }