1. 排行榜悬浮窗关闭后小按钮消失问题 - 修复面板居中模式关闭时按钮位置错误 - 保存并恢复按钮的居中状态 2. 首页tag按钮按素材数量排序 - 添加按素材数量降序排序逻辑 3. 上传弹窗输入框浅色模式文字不可见 - 添加 text-slate-900 确保浅色模式下文字可见 - 添加 placeholder 样式确保占位符正确显示
636 lines
31 KiB
TypeScript
636 lines
31 KiB
TypeScript
'use client'
|
||
|
||
import { Fragment, DragEvent, useState, useMemo } from 'react'
|
||
import { cardStyles, inputStyles, inputModifiers, cn } from '@/lib/ui-styles'
|
||
import type { FieldForm, DragHoverState } from '../../template-manager/types'
|
||
import FieldValidationConfig from './FieldValidationConfig'
|
||
import FieldConditionsConfig from './FieldConditionsConfig'
|
||
|
||
interface StandardFieldOption {
|
||
key: string
|
||
label: string
|
||
}
|
||
|
||
interface LayoutOption {
|
||
value: string
|
||
label: string
|
||
}
|
||
|
||
interface TemplateFieldsEditorProps {
|
||
fields: FieldForm[]
|
||
dragFieldIndex: number | null
|
||
dragHover: DragHoverState | null
|
||
editingFieldIndex: number | null
|
||
standardFields: StandardFieldOption[]
|
||
getStandardFieldTemplate?: (key: string) => FieldForm | null
|
||
fieldTypes: string[]
|
||
layoutOptions: LayoutOption[]
|
||
lockedCategoryFields?: Set<string>
|
||
onEditingFieldChange: (index: number | null) => void
|
||
onFieldDragStart: (event: DragEvent<HTMLElement>, index: number) => void
|
||
onFieldDragOver: (event: DragEvent<HTMLDivElement>, index: number, surface: 'editor') => void
|
||
onFieldDrop: (event: DragEvent<HTMLDivElement>) => void
|
||
onFieldDragEnd: () => void
|
||
onContainerDragOver: (event: DragEvent<HTMLDivElement>) => void
|
||
onContainerDrop: (event: DragEvent<HTMLDivElement>) => void
|
||
onContainerDragLeave: (event: DragEvent<HTMLDivElement>) => void
|
||
onMoveField: (index: number, direction: 'up' | 'down') => void
|
||
onRemoveField: (index: number) => void
|
||
onDuplicateField?: (index: number) => void
|
||
onAddField: () => void
|
||
renderDropPlaceholder: (key: string) => React.ReactNode
|
||
onUpdateField: (index: number, patch: Partial<FieldForm>) => void
|
||
}
|
||
|
||
// 字段key验证函数
|
||
function validateFieldKey(key: string, currentIndex: number, allFields: FieldForm[]): {
|
||
valid: boolean
|
||
error?: string
|
||
warning?: string
|
||
} {
|
||
const trimmedKey = key.trim()
|
||
|
||
// 空值检查
|
||
if (!trimmedKey) {
|
||
return { valid: false, error: '字段键名不能为空' }
|
||
}
|
||
|
||
// 格式检查:只允许字母、数字、下划线,且必须以字母开头
|
||
const keyPattern = /^[a-zA-Z][a-zA-Z0-9_]*$/
|
||
if (!keyPattern.test(trimmedKey)) {
|
||
return {
|
||
valid: false,
|
||
error: '键名必须以字母开头,只能包含字母、数字和下划线'
|
||
}
|
||
}
|
||
|
||
// 长度检查
|
||
if (trimmedKey.length > 50) {
|
||
return { valid: false, error: '键名长度不能超过50个字符' }
|
||
}
|
||
|
||
// 唯一性检查
|
||
const duplicateIndex = allFields.findIndex(
|
||
(f, idx) => idx !== currentIndex && f.key.trim() === trimmedKey
|
||
)
|
||
if (duplicateIndex !== -1) {
|
||
return {
|
||
valid: false,
|
||
error: `键名重复:字段 ${duplicateIndex + 1} 已使用此键名`
|
||
}
|
||
}
|
||
|
||
// 警告:建议使用驼峰命名
|
||
if (trimmedKey.includes('_') && trimmedKey.toLowerCase() === trimmedKey) {
|
||
return {
|
||
valid: true,
|
||
warning: '建议使用驼峰命名(如 productModel)而非下划线命名'
|
||
}
|
||
}
|
||
|
||
return { valid: true }
|
||
}
|
||
|
||
export default function TemplateFieldsEditor({
|
||
fields,
|
||
dragFieldIndex,
|
||
dragHover,
|
||
editingFieldIndex,
|
||
standardFields,
|
||
getStandardFieldTemplate,
|
||
fieldTypes,
|
||
layoutOptions,
|
||
lockedCategoryFields,
|
||
onEditingFieldChange,
|
||
onFieldDragStart,
|
||
onFieldDragOver,
|
||
onFieldDrop,
|
||
onFieldDragEnd,
|
||
onContainerDragOver,
|
||
onContainerDrop,
|
||
onContainerDragLeave,
|
||
onMoveField,
|
||
onRemoveField,
|
||
onDuplicateField,
|
||
onAddField,
|
||
renderDropPlaceholder,
|
||
onUpdateField,
|
||
}: TemplateFieldsEditorProps) {
|
||
// 删除确认对话框状态
|
||
const [deleteConfirmIndex, setDeleteConfirmIndex] = useState<number | null>(null)
|
||
|
||
// 实时验证所有字段的key
|
||
const fieldValidations = useMemo(() => {
|
||
return fields.map((field, index) => validateFieldKey(field.key, index, fields))
|
||
}, [fields])
|
||
|
||
// 复制字段功能
|
||
const handleDuplicateField = (index: number) => {
|
||
if (onDuplicateField) {
|
||
onDuplicateField(index)
|
||
}
|
||
}
|
||
|
||
// 删除字段(带确认)
|
||
const handleDeleteField = (index: number) => {
|
||
if (fields.length === 1) {
|
||
// 不显示确认对话框,直接提示
|
||
return
|
||
}
|
||
setDeleteConfirmIndex(index)
|
||
}
|
||
|
||
const confirmDelete = () => {
|
||
if (deleteConfirmIndex !== null) {
|
||
onRemoveField(deleteConfirmIndex)
|
||
setDeleteConfirmIndex(null)
|
||
}
|
||
}
|
||
|
||
const cancelDelete = () => {
|
||
setDeleteConfirmIndex(null)
|
||
}
|
||
|
||
return (
|
||
<div className={cn(cardStyles.base, 'p-4 space-y-5')}>
|
||
<div>
|
||
<h4 className="text-lg font-semibold text-slate-900 dark:text-white">字段配置</h4>
|
||
<p className="text-sm text-slate-500 dark:text-slate-400 mt-1">添加用户上传时需要填写的字段</p>
|
||
<p className="text-[11px] text-slate-400 dark:text-slate-500">
|
||
先选预设再按需调整,使用上下箭头按钮调整字段顺序。
|
||
</p>
|
||
</div>
|
||
|
||
<div
|
||
className="space-y-3"
|
||
onDragOver={onContainerDragOver}
|
||
onDrop={onContainerDrop}
|
||
onDragLeave={onContainerDragLeave}
|
||
>
|
||
{fields.map((field, index) => {
|
||
const isDragging = dragFieldIndex === index
|
||
const placeholderBefore =
|
||
dragFieldIndex !== null &&
|
||
dragHover &&
|
||
dragHover.index === index &&
|
||
dragHover.position === 'before'
|
||
const placeholderAfter =
|
||
dragFieldIndex !== null &&
|
||
dragHover &&
|
||
dragHover.index === index &&
|
||
dragHover.position === 'after'
|
||
|
||
const standardOption = standardFields.find((opt) => opt.key === field.key)
|
||
const selectValue = standardOption ? standardOption.key : '__custom'
|
||
|
||
// 判断是否为下拉选择类型 - 使用多种方式确保准确性
|
||
const fieldTypeStr = String(field.type || '').trim()
|
||
const isSelectType = fieldTypeStr === 'select'
|
||
const isLockedCategoryField = Boolean(lockedCategoryFields?.has(field.key))
|
||
|
||
// 获取当前字段的验证结果
|
||
const validation = fieldValidations[index]
|
||
const hasError = !validation.valid
|
||
const hasWarning = validation.valid && validation.warning
|
||
|
||
return (
|
||
<Fragment key={`editor-field-${index}`}>
|
||
{placeholderBefore && renderDropPlaceholder(`editor-before-${index}`)}
|
||
<div
|
||
id={`editor-field-${index}`}
|
||
className={`group border rounded-xl p-4 transition-all duration-200 ease-out ${
|
||
hasError
|
||
? 'border-red-300 dark:border-red-600 bg-red-50/50 dark:bg-red-900/10'
|
||
: editingFieldIndex === index
|
||
? 'border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-800 shadow-md'
|
||
: 'border-slate-200/60 dark:border-slate-700/60 bg-slate-50/50 dark:bg-slate-800/40 hover:border-slate-300 dark:hover:border-slate-600 hover:bg-white dark:hover:bg-slate-800 hover:shadow-sm'
|
||
} ${
|
||
isDragging
|
||
? 'opacity-40 scale-[0.96] cursor-grabbing border-blue-500 dark:border-blue-400 shadow-lg shadow-blue-200/50 dark:shadow-blue-900/50 transition-all duration-200'
|
||
: 'cursor-default'
|
||
}`}
|
||
draggable={true}
|
||
onDragStart={(e) => onFieldDragStart(e, index)}
|
||
onDragOver={(e) => onFieldDragOver(e, index, 'editor')}
|
||
onDrop={onFieldDrop}
|
||
onDragEnd={onFieldDragEnd}
|
||
onMouseEnter={() => onEditingFieldChange(index)}
|
||
onMouseLeave={() => onEditingFieldChange(null)}
|
||
>
|
||
<div className="flex items-center justify-between mb-3">
|
||
<div className="flex items-center gap-2">
|
||
{/* 拖拽手柄 */}
|
||
<div
|
||
className="cursor-grab active:cursor-grabbing p-1 hover:bg-slate-200 dark:hover:bg-slate-700 rounded transition-colors"
|
||
title="拖拽排序"
|
||
>
|
||
<svg className="w-4 h-4 text-slate-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 8h16M4 16h16" />
|
||
</svg>
|
||
</div>
|
||
<span className="px-2 py-0.5 text-xs font-semibold bg-slate-200 dark:bg-slate-700 text-slate-700 dark:text-slate-300 rounded">
|
||
字段 {index + 1}
|
||
</span>
|
||
<span className="text-sm font-medium text-slate-900 dark:text-white">
|
||
{field.label || field.key || '未命名字段'}
|
||
</span>
|
||
{/* 验证状态图标 */}
|
||
{hasError && (
|
||
<svg className="w-4 h-4 text-red-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||
</svg>
|
||
)}
|
||
{hasWarning && (
|
||
<svg className="w-4 h-4 text-yellow-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
|
||
</svg>
|
||
)}
|
||
</div>
|
||
<div className="flex items-center gap-1.5">
|
||
{/* 复制按钮 */}
|
||
<button
|
||
onClick={(e) => {
|
||
e.stopPropagation()
|
||
handleDuplicateField(index)
|
||
}}
|
||
className="p-1.5 text-slate-500 dark:text-slate-400 hover:text-blue-600 dark:hover:text-blue-400 hover:bg-blue-50 dark:hover:bg-blue-900/30 rounded-lg transition-all duration-200"
|
||
title="复制字段"
|
||
>
|
||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" />
|
||
</svg>
|
||
</button>
|
||
<button
|
||
onClick={(e) => {
|
||
e.stopPropagation()
|
||
onMoveField(index, 'up')
|
||
}}
|
||
disabled={index === 0}
|
||
className="p-1.5 text-slate-500 dark:text-slate-400 hover:text-slate-700 dark:hover:text-slate-200 hover:bg-slate-200 dark:hover:bg-slate-700 rounded-lg transition-all duration-200 disabled:opacity-40 disabled:cursor-not-allowed"
|
||
title="上移"
|
||
>
|
||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 15l7-7 7 7" />
|
||
</svg>
|
||
</button>
|
||
<button
|
||
onClick={(e) => {
|
||
e.stopPropagation()
|
||
onMoveField(index, 'down')
|
||
}}
|
||
disabled={index === fields.length - 1}
|
||
className="p-1.5 text-slate-500 dark:text-slate-400 hover:text-slate-700 dark:hover:text-slate-200 hover:bg-slate-200 dark:hover:bg-slate-700 rounded-lg transition-all duration-200 disabled:opacity-40 disabled:cursor-not-allowed"
|
||
title="下移"
|
||
>
|
||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
||
</svg>
|
||
</button>
|
||
<button
|
||
onClick={(e) => {
|
||
e.stopPropagation()
|
||
handleDeleteField(index)
|
||
}}
|
||
className="p-1.5 text-red-500 dark:text-red-400 hover:text-red-700 dark:hover:text-red-300 hover:bg-red-50 dark:hover:bg-red-900/30 rounded-lg transition-all duration-200"
|
||
title="删除"
|
||
>
|
||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||
</svg>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
{/* 验证错误/警告提示 */}
|
||
{(hasError || hasWarning) && (
|
||
<div className={`mb-3 p-2 rounded-lg text-xs ${
|
||
hasError
|
||
? 'bg-red-100 dark:bg-red-900/20 text-red-700 dark:text-red-300 border border-red-200 dark:border-red-800'
|
||
: 'bg-yellow-100 dark:bg-yellow-900/20 text-yellow-700 dark:text-yellow-300 border border-yellow-200 dark:border-yellow-800'
|
||
}`}>
|
||
<div className="flex items-start gap-2">
|
||
<svg className="w-4 h-4 flex-shrink-0 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
{hasError ? (
|
||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||
) : (
|
||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
|
||
)}
|
||
</svg>
|
||
<span>{hasError ? validation.error : validation.warning}</span>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
<div className="grid grid-cols-2 gap-3">
|
||
<div>
|
||
<label className="block text-xs font-medium text-slate-700 dark:text-slate-300 mb-1.5">
|
||
字段键名 <span className="text-red-500">*</span>
|
||
</label>
|
||
<div className="space-y-1.5">
|
||
<select
|
||
value={selectValue}
|
||
onChange={(e) => {
|
||
e.stopPropagation()
|
||
const value = e.target.value
|
||
if (value === '__custom') {
|
||
onUpdateField(index, { key: '', label: '', autoLabel: field.autoLabel })
|
||
return
|
||
}
|
||
const option = standardFields.find((item) => item.key === value)
|
||
if (!option) return
|
||
|
||
// 如果提供了获取标准字段模板的函数,应用完整的模板
|
||
if (getStandardFieldTemplate) {
|
||
const template = getStandardFieldTemplate(option.key)
|
||
if (template) {
|
||
// 应用完整模板,但保留用户可能已修改的 autoLabel
|
||
const shouldAutoLabel = field.autoLabel !== false
|
||
const patch: Partial<FieldForm> = {
|
||
...template,
|
||
autoLabel: shouldAutoLabel ? true : field.autoLabel,
|
||
}
|
||
if (!shouldAutoLabel) {
|
||
patch.label = field.label || template.label
|
||
}
|
||
onUpdateField(index, patch)
|
||
return
|
||
}
|
||
}
|
||
|
||
// 如果没有模板函数,只更新 key 和 label(向后兼容)
|
||
const shouldAutoLabel = field.autoLabel !== false
|
||
const patch: Partial<FieldForm> = {
|
||
key: option.key,
|
||
autoLabel: shouldAutoLabel ? true : field.autoLabel,
|
||
}
|
||
if (shouldAutoLabel) {
|
||
patch.label = option.label
|
||
}
|
||
onUpdateField(index, patch)
|
||
}}
|
||
onClick={(e) => e.stopPropagation()}
|
||
className={cn(inputStyles.base, inputModifiers.compact)}
|
||
>
|
||
<option value="__custom">自定义字段(手动输入键名)</option>
|
||
<optgroup label="标准字段">
|
||
{standardFields.map((option) => (
|
||
<option key={option.key} value={option.key}>
|
||
{option.label}({option.key})
|
||
</option>
|
||
))}
|
||
</optgroup>
|
||
</select>
|
||
{selectValue === '__custom' ? (
|
||
<input
|
||
key={`field-key-input-${index}`}
|
||
type="text"
|
||
value={field.key || ''}
|
||
onChange={(e) => {
|
||
e.stopPropagation()
|
||
onUpdateField(index, { key: e.target.value })
|
||
}}
|
||
onClick={(e) => e.stopPropagation()}
|
||
className="w-full px-3 py-2 text-sm border border-slate-300 dark:border-slate-600 dark:bg-slate-900 dark:text-white rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none transition-all duration-200"
|
||
placeholder="例如:productModel"
|
||
autoComplete="off"
|
||
/>
|
||
) : (
|
||
<p className="text-[11px] text-slate-500 dark:text-slate-400">
|
||
键名:<span className="font-mono">{field.key}</span>
|
||
</p>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
<div>
|
||
<label className="block text-xs font-medium text-slate-700 dark:text-slate-300 mb-1.5">显示名称</label>
|
||
<input
|
||
key={`field-label-input-${index}`}
|
||
type="text"
|
||
value={field.label || ''}
|
||
onChange={(e) => {
|
||
e.stopPropagation()
|
||
onUpdateField(index, { label: e.target.value, autoLabel: false })
|
||
}}
|
||
onClick={(e) => e.stopPropagation()}
|
||
className="w-full px-3 py-2 text-sm border border-slate-300 dark:border-slate-600 dark:bg-slate-900 dark:text-white rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none transition-all duration-200"
|
||
placeholder="例如:商品型号"
|
||
autoComplete="off"
|
||
/>
|
||
</div>
|
||
|
||
<div>
|
||
<label className="block text-xs font-medium text-slate-700 dark:text-slate-300 mb-1.5">字段类型</label>
|
||
<select
|
||
value={field.type}
|
||
onChange={(e) => {
|
||
onUpdateField(index, { type: e.target.value })
|
||
}}
|
||
onClick={(e) => e.stopPropagation()}
|
||
className="w-full px-3 py-2 text-sm border border-slate-300 dark:border-slate-600 dark:bg-slate-900 dark:text-white rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none transition-all duration-200"
|
||
>
|
||
{fieldTypes
|
||
.filter((type) => type !== 'image' && type !== 'file')
|
||
.map((type) => (
|
||
<option key={type} value={type}>
|
||
{type === 'text'
|
||
? '文本'
|
||
: type === 'textarea'
|
||
? '多行文本'
|
||
: type === 'number'
|
||
? '数字'
|
||
: type === 'select'
|
||
? '下拉选择'
|
||
: type === 'checkbox'
|
||
? '复选框'
|
||
: type === 'radio'
|
||
? '单选'
|
||
: type === 'date'
|
||
? '日期'
|
||
: type === 'uppercase'
|
||
? '仅大写+字母'
|
||
: type}
|
||
</option>
|
||
))}
|
||
</select>
|
||
</div>
|
||
|
||
<div>
|
||
<label className="block text-xs font-medium text-slate-700 dark:text-slate-300 mb-1.5">布局宽度</label>
|
||
<select
|
||
value={field.layoutWidth}
|
||
onChange={(e) => onUpdateField(index, { layoutWidth: e.target.value })}
|
||
onClick={(e) => e.stopPropagation()}
|
||
className="w-full px-3 py-2 text-sm border border-slate-300 dark:border-slate-600 dark:bg-slate-900 dark:text-white rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none transition-all duration-200"
|
||
>
|
||
{layoutOptions.map((option) => (
|
||
<option key={option.value} value={option.value}>
|
||
{option.label}
|
||
</option>
|
||
))}
|
||
</select>
|
||
</div>
|
||
</div>
|
||
|
||
{/* 选项管理提示 - 仅在select或radio类型时显示 */}
|
||
{(isSelectType || field.type === 'radio') && (
|
||
<div className="mt-3 p-3 bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg">
|
||
<div className="flex items-start gap-2">
|
||
<svg className="w-4 h-4 text-blue-500 dark:text-blue-400 flex-shrink-0 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||
</svg>
|
||
<div className="text-xs text-blue-700 dark:text-blue-300">
|
||
<p className="font-medium mb-1">下拉选项管理</p>
|
||
<p className="text-blue-600 dark:text-blue-400">
|
||
勾选「添加到分类」后,可在分类管理中统一管理此字段的选项值。
|
||
这样可以确保所有模板使用一致的选项,便于维护。
|
||
</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
<div className="flex flex-wrap gap-3 mt-3 pt-3 border-t border-slate-200/60 dark:border-slate-700/60">
|
||
<label className="flex items-center gap-1.5 text-xs text-slate-700 dark:text-slate-300 cursor-pointer">
|
||
<input
|
||
type="checkbox"
|
||
checked={field.required ?? false}
|
||
onChange={(e) => onUpdateField(index, { required: e.target.checked })}
|
||
onClick={(e) => e.stopPropagation()}
|
||
className="w-3.5 h-3.5 rounded border-slate-300 dark:border-slate-600 text-blue-600 focus:ring-2 focus:ring-blue-500"
|
||
/>
|
||
<span>必填</span>
|
||
</label>
|
||
<label className="flex items-center gap-1.5 text-xs text-slate-700 dark:text-slate-300 cursor-pointer">
|
||
<input
|
||
type="checkbox"
|
||
checked={field.isSearchable ?? false}
|
||
onChange={(e) => onUpdateField(index, { isSearchable: e.target.checked })}
|
||
onClick={(e) => e.stopPropagation()}
|
||
className="w-3.5 h-3.5 rounded border-slate-300 dark:border-slate-600 text-blue-600 focus:ring-2 focus:ring-blue-500"
|
||
/>
|
||
<span>可搜索</span>
|
||
</label>
|
||
{/* 添加到分类勾选框 - 仅在下拉选择类型时显示 */}
|
||
{isSelectType ? (
|
||
<label
|
||
className={cn(
|
||
'flex items-center gap-1.5 text-xs text-slate-700 dark:text-slate-300',
|
||
isLockedCategoryField ? 'cursor-not-allowed opacity-70' : 'cursor-pointer',
|
||
)}
|
||
title={isLockedCategoryField ? '已全局启用,统一由分类管理控制' : undefined}
|
||
>
|
||
<input
|
||
type="checkbox"
|
||
checked={isLockedCategoryField ? true : Boolean(field.addToCategory)}
|
||
disabled={isLockedCategoryField}
|
||
onChange={(e) => {
|
||
onUpdateField(index, { addToCategory: e.target.checked })
|
||
}}
|
||
onClick={(e) => e.stopPropagation()}
|
||
className="w-3.5 h-3.5 rounded border-slate-300 dark:border-slate-600 text-blue-600 focus:ring-2 focus:ring-blue-500"
|
||
/>
|
||
<span>
|
||
添加到分类
|
||
{isLockedCategoryField && (
|
||
<span className="ml-1 text-[10px] text-slate-400">(全局同步)</span>
|
||
)}
|
||
</span>
|
||
</label>
|
||
) : (
|
||
// 调试信息:显示为什么不显示复选框
|
||
editingFieldIndex === index && field.type === 'select' && (
|
||
<div className="text-[10px] text-red-500 dark:text-red-400 font-mono">
|
||
调试: type="{field.type}", isSelectType={String(isSelectType)}
|
||
</div>
|
||
)
|
||
)}
|
||
</div>
|
||
|
||
{/* 验证规则配置 */}
|
||
<FieldValidationConfig
|
||
fieldType={field.type}
|
||
validations={field.validations || ''}
|
||
onChange={(validations) => onUpdateField(index, { validations })}
|
||
/>
|
||
|
||
{/* 条件显示配置 */}
|
||
<FieldConditionsConfig
|
||
currentFieldKey={field.key}
|
||
conditions={field.conditions || ''}
|
||
availableFields={fields.map(f => ({ key: f.key, label: f.label || f.key }))}
|
||
onChange={(conditions) => onUpdateField(index, { conditions })}
|
||
/>
|
||
</div>
|
||
{placeholderAfter && renderDropPlaceholder(`editor-after-${index}`)}
|
||
</Fragment>
|
||
)
|
||
})}
|
||
|
||
<button
|
||
onClick={onAddField}
|
||
className="w-full border-2 border-dashed border-slate-300 dark:border-slate-600 rounded-lg p-4 transition-all duration-200 hover:border-slate-400 dark:hover:border-slate-500 hover:bg-slate-50 dark:hover:bg-slate-800/50 group"
|
||
>
|
||
<div className="flex items-center justify-center gap-3">
|
||
<div className="w-14 h-14 rounded-full bg-slate-100 dark:bg-slate-700 flex items-center justify-center flex-shrink-0 group-hover:bg-slate-200 dark:group-hover:bg-slate-600 transition-colors">
|
||
<svg className="w-7 h-7 text-slate-400 group-hover:text-slate-600 dark:group-hover:text-slate-300 transition-colors" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
|
||
</svg>
|
||
</div>
|
||
<div className="text-left">
|
||
<p className="text-xs font-medium text-slate-600 dark:text-slate-400 group-hover:text-slate-900 dark:group-hover:text-slate-200 transition-colors">
|
||
添加元素
|
||
</p>
|
||
<p className="text-[10px] text-slate-500 dark:text-slate-500 mt-0.5">点击添加新的字段配置</p>
|
||
</div>
|
||
</div>
|
||
</button>
|
||
</div>
|
||
|
||
{/* 删除确认对话框 */}
|
||
{deleteConfirmIndex !== null && (
|
||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm animate-in fade-in duration-200">
|
||
<div className="bg-white dark:bg-slate-800 rounded-2xl shadow-2xl max-w-md w-full mx-4 animate-in zoom-in-95 duration-200">
|
||
<div className="p-6">
|
||
<div className="flex items-start gap-4">
|
||
<div className="flex-shrink-0 w-12 h-12 rounded-full bg-red-100 dark:bg-red-900/30 flex items-center justify-center">
|
||
<svg className="w-6 h-6 text-red-600 dark:text-red-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
|
||
</svg>
|
||
</div>
|
||
<div className="flex-1">
|
||
<h3 className="text-lg font-semibold text-slate-900 dark:text-white mb-2">
|
||
确认删除字段
|
||
</h3>
|
||
<p className="text-sm text-slate-600 dark:text-slate-400 mb-1">
|
||
确定要删除字段「{fields[deleteConfirmIndex]?.label || fields[deleteConfirmIndex]?.key || '未命名字段'}」吗?
|
||
</p>
|
||
<p className="text-xs text-slate-500 dark:text-slate-500">
|
||
此操作不可撤销,已使用此模板的素材将无法显示该字段的数据。
|
||
</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div className="flex items-center justify-end gap-3 px-6 py-4 bg-slate-50 dark:bg-slate-900/50 rounded-b-2xl border-t border-slate-200 dark:border-slate-700">
|
||
<button
|
||
onClick={cancelDelete}
|
||
className="px-4 py-2 text-sm font-medium text-slate-700 dark:text-slate-300 hover:bg-slate-200 dark:hover:bg-slate-700 rounded-lg transition-colors"
|
||
>
|
||
取消
|
||
</button>
|
||
<button
|
||
onClick={confirmDelete}
|
||
className="px-4 py-2 text-sm font-medium text-white bg-red-600 hover:bg-red-700 dark:bg-red-500 dark:hover:bg-red-600 rounded-lg transition-colors"
|
||
>
|
||
确认删除
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
|