You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
cxxfds-web/src/utils/formSchemaEditor.ts

220 lines
7.9 KiB

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

/** 报名表 / 评审表 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
}