WuYongJie d1c6efc79d
Some checks failed
CI Check / check (push) Has been cancelled
feat: NAS 部署与 Harbor 推送流程
2025-12-03 14:39:06 +08:00

411 lines
20 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

'use client'
import { Fragment, DragEvent } from 'react'
import { cardStyles, inputStyles, inputModifiers, cn } from '@/lib/ui-styles'
import type { FieldForm, DragHoverState } from '../../template-manager/types'
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
onAddField: () => void
renderDropPlaceholder: (key: string) => React.ReactNode
onUpdateField: (index: number, patch: Partial<FieldForm>) => void
}
export default function TemplateFieldsEditor({
fields,
dragFieldIndex,
dragHover,
editingFieldIndex,
standardFields,
getStandardFieldTemplate,
fieldTypes,
layoutOptions,
lockedCategoryFields,
onEditingFieldChange,
onFieldDragStart,
onFieldDragOver,
onFieldDrop,
onFieldDragEnd,
onContainerDragOver,
onContainerDrop,
onContainerDragLeave,
onMoveField,
onRemoveField,
onAddField,
renderDropPlaceholder,
onUpdateField,
}: TemplateFieldsEditorProps) {
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))
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 ${
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={false}
onMouseEnter={() => onEditingFieldChange(index)}
onMouseLeave={() => onEditingFieldChange(null)}
>
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-2">
<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>
</div>
<div className="flex items-center gap-1.5">
<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()
onRemoveField(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>
<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>
{/* 下拉选项已移除,选项在分类管理中管理 */}
<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=&quot;{field.type}&quot;, isSelectType={String(isSelectType)}
</div>
)
)}
</div>
</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>
</div>
)
}