|
|
/** 报名表 / 评审表 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<string, unknown>
|
|
|
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<string, unknown>
|
|
|
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<string, unknown> = {
|
|
|
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<string>()
|
|
|
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
|
|
|
}
|