320 lines
8.9 KiB
TypeScript
320 lines
8.9 KiB
TypeScript
import {
|
||
detectCircularDependencies,
|
||
getAllOperators,
|
||
parseFieldConditions,
|
||
type FieldConditions,
|
||
} from './field-conditions'
|
||
|
||
const ALLOWED_TEMPLATE_STATUSES = new Set(['draft', 'active', 'archived'])
|
||
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 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')
|
||
const addToCategory = !!field.addToCategory
|
||
const normalizedOptions = normalizeStaticOptions(field.optionsStatic)
|
||
const optionsStatic = normalizedOptions.value
|
||
const normalizedConditions = normalizeConditions(field.conditions)
|
||
const conditions = normalizedConditions.value
|
||
|
||
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 (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: field.validations === null || field.validations === undefined ? null : String(field.validations),
|
||
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,
|
||
}
|
||
}
|