427 lines
12 KiB
TypeScript
427 lines
12 KiB
TypeScript
import {
|
||
detectCircularDependencies,
|
||
getAllOperators,
|
||
parseFieldConditions,
|
||
type FieldConditions,
|
||
} from './field-conditions'
|
||
import {
|
||
parseValidationRules,
|
||
validateValidationRules,
|
||
getSupportedValidations,
|
||
type ValidationRule,
|
||
} from './field-validation'
|
||
|
||
const ALLOWED_TEMPLATE_STATUSES = new Set(['draft', 'active', 'archived'])
|
||
const ALLOWED_FIELD_TYPES = new Set([
|
||
'text',
|
||
'textarea',
|
||
'number',
|
||
'select',
|
||
'checkbox',
|
||
'radio',
|
||
'date',
|
||
'uppercase',
|
||
])
|
||
const OPTION_FIELD_TYPES = new Set(['select', 'radio'])
|
||
const CONDITION_OPERATORS = new Set(getAllOperators().map((operator) => operator.value))
|
||
const CONDITION_OPERATORS_REQUIRING_VALUE = new Set(
|
||
getAllOperators()
|
||
.filter((operator) => operator.needsValue)
|
||
.map((operator) => operator.value)
|
||
)
|
||
|
||
export type FormattedUploadTemplateField = {
|
||
id?: string | null
|
||
key: string
|
||
label: string
|
||
type: string
|
||
required: boolean
|
||
orderIndex: number
|
||
layoutWidth: string
|
||
validations: string | null
|
||
optionsSource: string | null
|
||
optionsStatic: string | null
|
||
allowedExtensions: string | null
|
||
visibleOnHomeFilter: boolean
|
||
isSearchable: boolean
|
||
isReadOnly: boolean
|
||
addToCategory: boolean
|
||
conditions: string | null
|
||
}
|
||
|
||
export type UploadTemplateSaveValidationResult =
|
||
| { valid: true; fields: FormattedUploadTemplateField[] }
|
||
| {
|
||
valid: false
|
||
error: string
|
||
field: string
|
||
details?: Record<string, unknown>
|
||
}
|
||
|
||
export function isValidUploadTemplateStatus(status: unknown): status is string {
|
||
return typeof status === 'string' && ALLOWED_TEMPLATE_STATUSES.has(status)
|
||
}
|
||
|
||
function normalizeStaticOptions(value: unknown): { value: string | null; invalid: boolean } {
|
||
if (value === null || value === undefined) {
|
||
return { value: null, invalid: false }
|
||
}
|
||
|
||
if (Array.isArray(value)) {
|
||
const options = value
|
||
.map((option) => String(option ?? '').trim())
|
||
.filter(Boolean)
|
||
return {
|
||
value: options.length > 0 ? JSON.stringify(Array.from(new Set(options))) : null,
|
||
invalid: false,
|
||
}
|
||
}
|
||
|
||
if (typeof value === 'string') {
|
||
const trimmed = value.trim()
|
||
if (!trimmed) {
|
||
return { value: null, invalid: false }
|
||
}
|
||
|
||
try {
|
||
const parsed = JSON.parse(trimmed)
|
||
return normalizeStaticOptions(parsed)
|
||
} catch {
|
||
const options = trimmed
|
||
.split('\n')
|
||
.map((option) => option.trim())
|
||
.filter(Boolean)
|
||
return {
|
||
value: options.length > 0 ? JSON.stringify(Array.from(new Set(options))) : null,
|
||
invalid: false,
|
||
}
|
||
}
|
||
}
|
||
|
||
return { value: null, invalid: true }
|
||
}
|
||
|
||
function parseNormalizedOptions(value: string | null): string[] {
|
||
if (!value) {
|
||
return []
|
||
}
|
||
|
||
try {
|
||
const parsed = JSON.parse(value)
|
||
return Array.isArray(parsed)
|
||
? parsed.map((option) => String(option ?? '').trim()).filter(Boolean)
|
||
: []
|
||
} catch {
|
||
return []
|
||
}
|
||
}
|
||
|
||
function normalizeValidations(
|
||
value: unknown,
|
||
fieldType: string,
|
||
): { value: string | null; invalid?: string; rules?: ValidationRule } {
|
||
if (value === null || value === undefined) {
|
||
return { value: null }
|
||
}
|
||
|
||
if (typeof value === 'string' && value.trim() === '') {
|
||
return { value: null }
|
||
}
|
||
|
||
let raw: unknown = value
|
||
if (typeof value === 'string') {
|
||
try {
|
||
raw = JSON.parse(value)
|
||
} catch {
|
||
return { value: null, invalid: '验证规则必须是合法 JSON 对象' }
|
||
}
|
||
}
|
||
|
||
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) {
|
||
return { value: null, invalid: '验证规则必须是对象' }
|
||
}
|
||
|
||
const parsedRules = parseValidationRules(JSON.stringify(raw))
|
||
if (!parsedRules) {
|
||
return { value: null, invalid: '验证规则必须是对象' }
|
||
}
|
||
|
||
const validation = validateValidationRules(parsedRules)
|
||
if (!validation.valid) {
|
||
return { value: null, invalid: validation.error || '验证规则配置无效' }
|
||
}
|
||
|
||
const supportedValidations = new Set(getSupportedValidations(fieldType))
|
||
const unsupportedRules = [
|
||
['minLength', parsedRules.minLength],
|
||
['maxLength', parsedRules.maxLength],
|
||
['pattern', parsedRules.pattern],
|
||
['min', parsedRules.min],
|
||
['max', parsedRules.max],
|
||
]
|
||
.filter(([ruleKey, ruleValue]) => ruleValue !== undefined && !supportedValidations.has(String(ruleKey)))
|
||
.map(([ruleKey]) => String(ruleKey))
|
||
|
||
if (unsupportedRules.length > 0) {
|
||
return {
|
||
value: null,
|
||
invalid: `字段类型 ${fieldType} 不支持验证规则:${unsupportedRules.join(', ')}`,
|
||
}
|
||
}
|
||
|
||
return {
|
||
value: JSON.stringify(parsedRules),
|
||
rules: parsedRules,
|
||
}
|
||
}
|
||
|
||
function normalizeConditions(
|
||
value: unknown,
|
||
): { value: string | null; invalid: boolean; conditions?: FieldConditions } {
|
||
if (value === null || value === undefined) {
|
||
return { value: null, invalid: false }
|
||
}
|
||
|
||
if (typeof value === 'string' && value.trim() === '') {
|
||
return { value: null, invalid: false }
|
||
}
|
||
|
||
let raw: any
|
||
try {
|
||
raw = typeof value === 'string' ? JSON.parse(value) : value
|
||
} catch {
|
||
return { value: null, invalid: true }
|
||
}
|
||
|
||
if (!raw || typeof raw !== 'object') {
|
||
return { value: null, invalid: true }
|
||
}
|
||
|
||
if (raw.logic !== undefined && raw.logic !== 'AND' && raw.logic !== 'OR') {
|
||
return { value: null, invalid: true }
|
||
}
|
||
|
||
if (!Array.isArray(raw.conditions)) {
|
||
return { value: null, invalid: true }
|
||
}
|
||
|
||
if (raw.conditions.length === 0) {
|
||
return { value: null, invalid: false }
|
||
}
|
||
|
||
for (const condition of raw.conditions) {
|
||
if (!condition || typeof condition !== 'object') {
|
||
return { value: null, invalid: true }
|
||
}
|
||
if (!condition.fieldKey || !condition.operator) {
|
||
return { value: null, invalid: true }
|
||
}
|
||
}
|
||
|
||
const parsed = parseFieldConditions(raw as FieldConditions)
|
||
if (!parsed || parsed.conditions.length !== raw.conditions.length) {
|
||
return { value: null, invalid: true }
|
||
}
|
||
|
||
return { value: JSON.stringify(parsed), invalid: false, conditions: parsed }
|
||
}
|
||
|
||
export function validateAndFormatUploadTemplateFields(
|
||
fields: unknown,
|
||
): UploadTemplateSaveValidationResult {
|
||
if (!Array.isArray(fields)) {
|
||
return {
|
||
valid: false,
|
||
error: '字段列表必须是数组',
|
||
field: 'fields',
|
||
}
|
||
}
|
||
|
||
const fieldKeys = new Set<string>()
|
||
const duplicateKeys: string[] = []
|
||
const formattedFields: FormattedUploadTemplateField[] = []
|
||
|
||
for (let index = 0; index < fields.length; index += 1) {
|
||
const rawField = fields[index]
|
||
const field = (rawField || {}) as Record<string, any>
|
||
const key = String(field.key || '').trim()
|
||
|
||
if (!key) {
|
||
return {
|
||
valid: false,
|
||
error: '字段key不能为空',
|
||
field: 'fields',
|
||
}
|
||
}
|
||
|
||
if (fieldKeys.has(key)) {
|
||
duplicateKeys.push(key)
|
||
} else {
|
||
fieldKeys.add(key)
|
||
}
|
||
|
||
const label = String(field.label || '').trim() || `字段${index + 1}`
|
||
const type = String(field.type || 'text').trim() || 'text'
|
||
const addToCategory = !!field.addToCategory
|
||
const normalizedOptions = normalizeStaticOptions(field.optionsStatic)
|
||
const optionsStatic = normalizedOptions.value
|
||
const normalizedValidations = normalizeValidations(field.validations, type)
|
||
const normalizedConditions = normalizeConditions(field.conditions)
|
||
const conditions = normalizedConditions.value
|
||
|
||
if (!/^[a-zA-Z][a-zA-Z0-9_]*$/.test(key)) {
|
||
return {
|
||
valid: false,
|
||
error: `字段“${label}”的 key 必须以字母开头,并且只能包含字母、数字和下划线`,
|
||
field: 'fields',
|
||
details: { fieldKey: key },
|
||
}
|
||
}
|
||
|
||
if (!ALLOWED_FIELD_TYPES.has(type)) {
|
||
return {
|
||
valid: false,
|
||
error: `字段“${label}”的类型不支持:${type}`,
|
||
field: 'fields',
|
||
details: {
|
||
fieldKey: key,
|
||
fieldType: type,
|
||
allowedTypes: Array.from(ALLOWED_FIELD_TYPES),
|
||
},
|
||
}
|
||
}
|
||
|
||
if (OPTION_FIELD_TYPES.has(type)) {
|
||
if (normalizedOptions.invalid) {
|
||
return {
|
||
valid: false,
|
||
error: `字段“${label}”的选项必须是数组或每行一个选项`,
|
||
field: 'fields',
|
||
details: { fieldKey: key },
|
||
}
|
||
}
|
||
|
||
const options = parseNormalizedOptions(optionsStatic)
|
||
if (field.required && !addToCategory && options.length === 0) {
|
||
return {
|
||
valid: false,
|
||
error: `字段“${label}”是必填${type === 'radio' ? '单选' : '下拉'}字段,至少需要配置一个选项`,
|
||
field: 'fields',
|
||
details: { fieldKey: key },
|
||
}
|
||
}
|
||
}
|
||
|
||
if (normalizedValidations.invalid) {
|
||
return {
|
||
valid: false,
|
||
error: `字段“${label}”的验证规则不正确:${normalizedValidations.invalid}`,
|
||
field: 'fields',
|
||
details: { fieldKey: key },
|
||
}
|
||
}
|
||
|
||
if (normalizedConditions.invalid) {
|
||
return {
|
||
valid: false,
|
||
error: `字段“${label}”的显示条件格式不正确`,
|
||
field: 'fields',
|
||
details: { fieldKey: key },
|
||
}
|
||
}
|
||
|
||
formattedFields.push({
|
||
id: field.id ?? null,
|
||
key,
|
||
label,
|
||
type,
|
||
required: !!field.required,
|
||
orderIndex: field.orderIndex ?? index,
|
||
layoutWidth: field.layoutWidth || 'full',
|
||
validations: normalizedValidations.value,
|
||
optionsSource: field.optionsSource === null || field.optionsSource === undefined ? null : String(field.optionsSource),
|
||
optionsStatic,
|
||
allowedExtensions: field.allowedExtensions === null || field.allowedExtensions === undefined ? null : String(field.allowedExtensions),
|
||
visibleOnHomeFilter: !!field.visibleOnHomeFilter,
|
||
isSearchable: !!field.isSearchable,
|
||
isReadOnly: !!field.isReadOnly,
|
||
addToCategory,
|
||
conditions,
|
||
})
|
||
}
|
||
|
||
if (duplicateKeys.length > 0) {
|
||
return {
|
||
valid: false,
|
||
error: `字段key重复:${duplicateKeys.join(', ')}`,
|
||
field: 'fields',
|
||
details: { duplicateKeys },
|
||
}
|
||
}
|
||
|
||
const availableKeys = new Set(formattedFields.map((field) => field.key))
|
||
|
||
for (const field of formattedFields) {
|
||
const conditions = parseFieldConditions(field.conditions)
|
||
if (!conditions) {
|
||
continue
|
||
}
|
||
|
||
for (const condition of conditions.conditions) {
|
||
if (!CONDITION_OPERATORS.has(condition.operator)) {
|
||
return {
|
||
valid: false,
|
||
error: `字段“${field.label}”的显示条件包含不支持的判断方式`,
|
||
field: 'fields',
|
||
details: { fieldKey: field.key, operator: condition.operator },
|
||
}
|
||
}
|
||
|
||
if (!availableKeys.has(condition.fieldKey)) {
|
||
return {
|
||
valid: false,
|
||
error: `字段“${field.label}”的显示条件引用了不存在的字段:${condition.fieldKey}`,
|
||
field: 'fields',
|
||
details: { fieldKey: field.key, dependencyKey: condition.fieldKey },
|
||
}
|
||
}
|
||
|
||
if (condition.fieldKey === field.key) {
|
||
return {
|
||
valid: false,
|
||
error: `字段“${field.label}”的显示条件不能引用自己`,
|
||
field: 'fields',
|
||
details: { fieldKey: field.key },
|
||
}
|
||
}
|
||
|
||
if (
|
||
CONDITION_OPERATORS_REQUIRING_VALUE.has(condition.operator) &&
|
||
String(condition.value ?? '').trim() === ''
|
||
) {
|
||
return {
|
||
valid: false,
|
||
error: `字段“${field.label}”的显示条件缺少比较值`,
|
||
field: 'fields',
|
||
details: { fieldKey: field.key, operator: condition.operator },
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
const circularFields = detectCircularDependencies(formattedFields)
|
||
if (circularFields.length > 0) {
|
||
return {
|
||
valid: false,
|
||
error: `字段显示条件存在循环依赖:${circularFields.join(', ')}`,
|
||
field: 'fields',
|
||
details: { circularFields },
|
||
}
|
||
}
|
||
|
||
return {
|
||
valid: true,
|
||
fields: formattedFields,
|
||
}
|
||
}
|