Some checks failed
CI Check / check (push) Has been cancelled
- 创建统一UI三层架构系统(基础层、颜色层、组合层) - 拆分Admin页面为独立Tab组件(MonitorTab、UsersTab、DepartmentsTab等) - 提取公共逻辑到Hooks(useToast、useDataFetch、useUserOperations等) - 提取工具函数(validators、formatters、api等) - 创建统一弹窗组件(Modal)和useModal Hook - 添加错误边界和统一状态组件 - 完善类型定义和可访问性支持 - 更新文档(优化记录、优化计划、更新日志)
686 lines
29 KiB
TypeScript
686 lines
29 KiB
TypeScript
'use client'
|
||
|
||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||
import { createPortal } from 'react-dom'
|
||
import {
|
||
closestCenter,
|
||
DndContext,
|
||
DragEndEvent,
|
||
DragOverEvent,
|
||
DragOverlay,
|
||
DragStartEvent,
|
||
PointerSensor,
|
||
defaultDropAnimationSideEffects,
|
||
useSensor,
|
||
useSensors,
|
||
} from '@dnd-kit/core'
|
||
import { SortableContext, rectSortingStrategy, useSortable } from '@dnd-kit/sortable'
|
||
import { CSS } from '@dnd-kit/utilities'
|
||
import type { FieldForm } from '../../template-manager/types'
|
||
|
||
type PreviewSortableItem = {
|
||
field: FieldForm
|
||
originalIndex: number
|
||
sortId: string
|
||
}
|
||
|
||
type PreviewRenderableItem =
|
||
| (PreviewSortableItem & { placeholder?: false })
|
||
| {
|
||
placeholder: true
|
||
sortId: string
|
||
layoutWidth: string
|
||
}
|
||
|
||
const getPreviewWidthStyle = (_layoutWidth?: string | null): React.CSSProperties => ({
|
||
width: '100%',
|
||
minWidth: 0,
|
||
})
|
||
|
||
const getPreviewWidthStyleValue = (layoutWidth?: string) => {
|
||
if (layoutWidth === 'full') {
|
||
return '520px'
|
||
}
|
||
return '320px'
|
||
}
|
||
|
||
interface TemplatePreviewPanelProps {
|
||
fields: FieldForm[]
|
||
isPreviewMode: boolean
|
||
editingFieldIndex: number | null
|
||
onEditingFieldChange: (index: number | null) => void
|
||
onScrollToField: (index: number) => void
|
||
onRemoveField: (index: number) => void
|
||
onFieldsReordered: (nextFields: FieldForm[], movedFieldLabel?: string) => void
|
||
}
|
||
|
||
export default function TemplatePreviewPanel({
|
||
fields,
|
||
isPreviewMode,
|
||
editingFieldIndex,
|
||
onEditingFieldChange,
|
||
onScrollToField,
|
||
onRemoveField,
|
||
onFieldsReordered,
|
||
}: TemplatePreviewPanelProps) {
|
||
const previewSensors = useSensors(
|
||
useSensor(PointerSensor, {
|
||
activationConstraint: {
|
||
distance: 6,
|
||
},
|
||
}),
|
||
)
|
||
const disabledSensors = useSensors()
|
||
|
||
const previewSortableFields = useMemo<PreviewSortableItem[]>(() => {
|
||
return fields
|
||
.map((field, index) => ({
|
||
field,
|
||
originalIndex: index,
|
||
sortId: `preview-${index}-${field.id ?? field.key ?? `field-${index}`}`,
|
||
}))
|
||
.filter((item) => item.field.type !== 'image' && item.field.type !== 'file')
|
||
}, [fields])
|
||
|
||
const previewIndexLookup = useMemo(() => {
|
||
const map = new Map<string, number>()
|
||
previewSortableFields.forEach((item) => {
|
||
map.set(item.sortId, item.originalIndex)
|
||
})
|
||
return map
|
||
}, [previewSortableFields])
|
||
|
||
const previewSortableMap = useMemo(() => {
|
||
const map = new Map<string, PreviewSortableItem>()
|
||
previewSortableFields.forEach((item) => {
|
||
map.set(item.sortId, item)
|
||
})
|
||
return map
|
||
}, [previewSortableFields])
|
||
|
||
const previewDefaultOrder = useMemo(
|
||
() => previewSortableFields.map((item) => item.sortId),
|
||
[previewSortableFields],
|
||
)
|
||
const [previewOrder, setPreviewOrder] = useState<string[]>(previewDefaultOrder)
|
||
const [previewActiveId, setPreviewActiveId] = useState<string | null>(null)
|
||
const [previewOverId, setPreviewOverId] = useState<string | null>(null)
|
||
const [previewActiveRect, setPreviewActiveRect] = useState<{ width: number; height: number } | null>(null)
|
||
const [previewPlaceholderVisible, setPreviewPlaceholderVisible] = useState(false)
|
||
const [previewPlaceholderIndex, setPreviewPlaceholderIndex] = useState<number | null>(null)
|
||
const pendingPlaceholderIndexRef = useRef<number | null>(null)
|
||
|
||
useEffect(() => {
|
||
if (!previewActiveId) {
|
||
setPreviewOrder(previewDefaultOrder)
|
||
setPreviewPlaceholderIndex(null)
|
||
} else {
|
||
setPreviewOrder((currentOrder) => {
|
||
if (currentOrder.length !== previewDefaultOrder.length) {
|
||
const activeId = previewActiveId
|
||
const newOrder = previewDefaultOrder.filter((id) => id !== activeId)
|
||
const currentIndex = currentOrder.indexOf(activeId)
|
||
if (currentIndex >= 0 && currentIndex < newOrder.length) {
|
||
newOrder.splice(currentIndex, 0, activeId)
|
||
} else {
|
||
newOrder.push(activeId)
|
||
}
|
||
return newOrder
|
||
}
|
||
return currentOrder
|
||
})
|
||
}
|
||
}, [previewDefaultOrder, previewActiveId])
|
||
|
||
useEffect(() => {
|
||
if (pendingPlaceholderIndexRef.current !== null) {
|
||
const index = pendingPlaceholderIndexRef.current
|
||
pendingPlaceholderIndexRef.current = null
|
||
requestAnimationFrame(() => {
|
||
setPreviewPlaceholderIndex(index)
|
||
})
|
||
}
|
||
}, [previewOrder])
|
||
|
||
const orderedPreviewItems = useMemo(() => {
|
||
return previewOrder.map((id) => previewSortableMap.get(id)).filter((item): item is PreviewSortableItem => !!item)
|
||
}, [previewOrder, previewSortableMap])
|
||
|
||
const previewActiveItem = previewActiveId ? previewSortableMap.get(previewActiveId) : null
|
||
const previewRenderableItems = useMemo<PreviewRenderableItem[]>(() => {
|
||
let items: PreviewRenderableItem[] = orderedPreviewItems
|
||
if (previewActiveId) {
|
||
items = items.filter((item) => item.sortId !== previewActiveId)
|
||
}
|
||
if (previewPlaceholderVisible && previewPlaceholderIndex !== null && previewActiveItem) {
|
||
const placeholder: PreviewRenderableItem = {
|
||
placeholder: true,
|
||
sortId: `${previewActiveItem.sortId}-placeholder`,
|
||
layoutWidth: previewActiveItem.field.layoutWidth,
|
||
}
|
||
const insertIndex = Math.max(0, Math.min(previewPlaceholderIndex, items.length))
|
||
const cloned = [...items]
|
||
cloned.splice(insertIndex, 0, placeholder)
|
||
return cloned
|
||
}
|
||
return items
|
||
}, [orderedPreviewItems, previewActiveId, previewActiveItem, previewPlaceholderIndex, previewPlaceholderVisible])
|
||
|
||
const resetPreviewDragState = useCallback(() => {
|
||
setPreviewActiveId(null)
|
||
setPreviewOverId(null)
|
||
setPreviewActiveRect(null)
|
||
setPreviewPlaceholderVisible(false)
|
||
setPreviewPlaceholderIndex(null)
|
||
pendingPlaceholderIndexRef.current = null
|
||
}, [])
|
||
|
||
const handlePreviewDragStart = useCallback(
|
||
(event: DragStartEvent) => {
|
||
const activeId = String(event.active.id)
|
||
const initialRect = event.active.rect.current?.initial
|
||
setPreviewActiveId(activeId)
|
||
setPreviewOverId(activeId)
|
||
setPreviewPlaceholderVisible(true)
|
||
setPreviewPlaceholderIndex(previewOrder.indexOf(activeId))
|
||
setPreviewOrder((order) => {
|
||
if (order.length === previewDefaultOrder.length) return order
|
||
return previewDefaultOrder
|
||
})
|
||
if (initialRect) {
|
||
setPreviewActiveRect({
|
||
width: initialRect.width,
|
||
height: initialRect.height,
|
||
})
|
||
} else {
|
||
setPreviewActiveRect(null)
|
||
}
|
||
},
|
||
[previewDefaultOrder, previewOrder],
|
||
)
|
||
|
||
const handlePreviewDragCancel = useCallback(() => {
|
||
setPreviewOrder(previewDefaultOrder)
|
||
resetPreviewDragState()
|
||
}, [previewDefaultOrder, resetPreviewDragState])
|
||
|
||
const handlePreviewDragOver = useCallback(
|
||
(event: DragOverEvent) => {
|
||
const { over, active } = event
|
||
|
||
if (!over) {
|
||
setPreviewOverId(null)
|
||
setPreviewPlaceholderVisible(false)
|
||
return
|
||
}
|
||
|
||
const overId = String(over.id)
|
||
const activeId = String(active.id)
|
||
|
||
if (overId.includes('-placeholder')) return
|
||
if (!previewSortableMap.has(overId) || !previewSortableMap.has(activeId)) {
|
||
setPreviewOverId(null)
|
||
setPreviewPlaceholderVisible(false)
|
||
return
|
||
}
|
||
|
||
setPreviewPlaceholderVisible((prev) => (prev ? prev : true))
|
||
setPreviewOverId((prev) => (prev !== overId ? overId : prev))
|
||
|
||
setPreviewOrder((items) => {
|
||
if (!items.includes(activeId)) {
|
||
return items
|
||
}
|
||
|
||
const currentIndex = items.indexOf(activeId)
|
||
const overIndex = items.indexOf(overId)
|
||
if (currentIndex === -1 || overIndex === -1) {
|
||
return items
|
||
}
|
||
|
||
const overRect = over.rect
|
||
const overItem = previewSortableMap.get(overId)
|
||
|
||
const delta = event.delta
|
||
let mouseX = 0
|
||
let mouseY = 0
|
||
|
||
const activeRect = event.active.rect.current.translated ?? event.active.rect.current.initial
|
||
if (activeRect && delta) {
|
||
mouseX = activeRect.left + activeRect.width / 2 + delta.x
|
||
mouseY = activeRect.top + activeRect.height / 2 + delta.y
|
||
} else if (overRect) {
|
||
mouseX = overRect.left + overRect.width / 2
|
||
mouseY = overRect.top + overRect.height / 2
|
||
}
|
||
|
||
let insertionIndex = overIndex
|
||
|
||
if (overRect && mouseX > 0 && mouseY > 0) {
|
||
const overCenterX = overRect.left + overRect.width / 2
|
||
const overCenterY = overRect.top + overRect.height / 2
|
||
const relativeX = mouseX - overCenterX
|
||
const relativeY = mouseY - overCenterY
|
||
const sameRow = Math.abs(relativeY) < overRect.height * 0.65
|
||
const edgeThresholdX = overRect.width * 0.3
|
||
const edgeThresholdY = overRect.height * 0.3
|
||
|
||
if (overItem?.field.layoutWidth === 'full' || !sameRow) {
|
||
if (Math.abs(relativeY) > edgeThresholdY) {
|
||
insertionIndex = mouseY > overCenterY ? overIndex + 1 : overIndex
|
||
} else {
|
||
insertionIndex = overIndex
|
||
}
|
||
} else {
|
||
if (Math.abs(relativeX) > edgeThresholdX) {
|
||
insertionIndex = mouseX > overCenterX ? overIndex + 1 : overIndex
|
||
} else {
|
||
insertionIndex = overIndex
|
||
}
|
||
}
|
||
}
|
||
|
||
const withoutActive = items.filter((id) => id !== activeId)
|
||
if (insertionIndex > currentIndex) {
|
||
insertionIndex = Math.max(0, insertionIndex - 1)
|
||
}
|
||
insertionIndex = Math.max(0, Math.min(insertionIndex, withoutActive.length))
|
||
|
||
const nextOrder = [...withoutActive]
|
||
nextOrder.splice(insertionIndex, 0, activeId)
|
||
|
||
if (nextOrder.length === items.length && nextOrder.every((id, idx) => id === items[idx])) {
|
||
return items
|
||
}
|
||
|
||
pendingPlaceholderIndexRef.current = insertionIndex
|
||
return nextOrder
|
||
})
|
||
},
|
||
[previewSortableMap],
|
||
)
|
||
|
||
const handlePreviewDragEnd = useCallback(
|
||
({ active }: DragEndEvent) => {
|
||
const activeId = String(active.id)
|
||
const activeIndex = previewIndexLookup.get(activeId)
|
||
const targetOrderIndex = previewOrder.indexOf(activeId)
|
||
|
||
if (activeIndex === undefined || targetOrderIndex === -1 || activeIndex === targetOrderIndex) {
|
||
resetPreviewDragState()
|
||
return
|
||
}
|
||
|
||
const reorderedFields = previewOrder
|
||
.map((sortId) => {
|
||
const item = previewSortableMap.get(sortId)
|
||
return item ? fields[item.originalIndex] : null
|
||
})
|
||
.filter((field): field is FieldForm => field !== null)
|
||
|
||
onFieldsReordered(reorderedFields, fields[activeIndex]?.label || fields[activeIndex]?.key)
|
||
resetPreviewDragState()
|
||
},
|
||
[fields, onFieldsReordered, previewIndexLookup, previewOrder, previewSortableMap, resetPreviewDragState],
|
||
)
|
||
|
||
const canEdit = !isPreviewMode
|
||
|
||
return (
|
||
<div
|
||
className={`overflow-y-auto bg-slate-50/30 dark:bg-slate-900/50 transition-all duration-500 ease-in-out ${
|
||
isPreviewMode ? 'w-[520px] mx-auto animate-in zoom-in-95' : 'w-1/2 animate-in slide-in-from-right'
|
||
}`}
|
||
>
|
||
<div className={isPreviewMode ? 'p-4' : 'p-6'}>
|
||
{!isPreviewMode && (
|
||
<div className="mb-5 pb-5 border-b border-slate-200/40 dark:border-slate-800/40">
|
||
<h4 className="text-xl font-semibold text-slate-900 dark:text-white mb-1.5 tracking-tight">实时预览</h4>
|
||
<p className="text-sm text-slate-500 dark:text-slate-400 font-light">预览上传面板的实际效果</p>
|
||
<p className="text-[11px] text-slate-400 dark:text-slate-500">右侧仅供预览,不可编辑,点击字段可定位到左侧配置。</p>
|
||
</div>
|
||
)}
|
||
|
||
{isPreviewMode && (
|
||
<div className="mb-4 text-center">
|
||
<h4 className="text-lg font-semibold text-slate-900 dark:text-white mb-1">模板预览</h4>
|
||
<p className="text-xs text-slate-500 dark:text-slate-400">完整的上传面板效果</p>
|
||
</div>
|
||
)}
|
||
|
||
<div
|
||
className={
|
||
isPreviewMode
|
||
? 'border-2 border-dashed border-slate-300 dark:border-slate-600 rounded-3xl p-4 bg-slate-50/30 dark:bg-slate-800/30'
|
||
: ''
|
||
}
|
||
>
|
||
<div
|
||
className={`bg-white/95 dark:bg-slate-800/95 backdrop-blur-xl rounded-2xl shadow-lg shadow-slate-200/30 dark:shadow-slate-900/30 border border-slate-200/40 dark:border-slate-700/40 space-y-5 ${
|
||
isPreviewMode ? 'p-6 border-2 border-slate-300 dark:border-slate-600 ring-2 ring-slate-200/50 dark:ring-slate-700/50' : 'p-5'
|
||
}`}
|
||
>
|
||
<div className="relative">
|
||
<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="border-2 border-dashed border-slate-300/60 dark:border-slate-600/60 rounded-2xl p-4 transition-all duration-300 ease-[cubic-bezier(0.4,0,0.2,1)] hover:border-slate-400/80 dark:hover:border-slate-500/80 hover:bg-slate-50/50 dark:hover:bg-slate-700/30 hover:shadow-md">
|
||
<div className="flex items-center justify-center space-x-3">
|
||
<div className="w-16 h-16 rounded-xl bg-gradient-to-br from-slate-100 to-slate-50 dark:from-slate-700 dark:to-slate-800 flex items-center justify-center flex-shrink-0 shadow-inner">
|
||
<svg className="w-8 h-8 text-slate-400 dark:text-slate-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
|
||
</svg>
|
||
</div>
|
||
<div className="text-left flex-1 min-w-0">
|
||
<p className="text-xs font-medium text-slate-700 dark:text-slate-300">点击或拖拽上传预览图</p>
|
||
<p className="text-[10px] text-slate-500 dark:text-slate-500 mt-0.5">支持 JPG、PNG、WebP 等格式</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="relative">
|
||
<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="border-2 border-dashed border-slate-300/60 dark:border-slate-600/60 rounded-2xl p-4 transition-all duration-300 ease-[cubic-bezier(0.4,0,0.2,1)] hover:border-slate-400/80 dark:hover:border-slate-500/80 hover:bg-slate-50/50 dark:hover:bg-slate-700/30 hover:shadow-md">
|
||
<div className="flex items-center justify-center space-x-3">
|
||
<svg className="w-8 h-8 text-slate-400 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12" />
|
||
</svg>
|
||
<div className="text-left flex-1 min-w-0">
|
||
<p className="text-xs text-slate-600 dark:text-slate-400">点击或拖拽上传素材文件</p>
|
||
<p className="text-[10px] text-slate-500 dark:text-slate-500 mt-0.5">支持 ZIP、RAR 等压缩格式</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<DndContext
|
||
sensors={isPreviewMode ? disabledSensors : previewSensors}
|
||
collisionDetection={closestCenter}
|
||
onDragStart={isPreviewMode ? undefined : handlePreviewDragStart}
|
||
onDragOver={isPreviewMode ? undefined : handlePreviewDragOver}
|
||
onDragCancel={isPreviewMode ? undefined : handlePreviewDragCancel}
|
||
onDragEnd={isPreviewMode ? undefined : handlePreviewDragEnd}
|
||
>
|
||
<SortableContext items={previewOrder} strategy={rectSortingStrategy}>
|
||
{orderedPreviewItems.length === 0 ? (
|
||
<div className="w-full rounded-2xl border-2 border-dashed border-slate-200/60 dark:border-slate-700/60 bg-gradient-to-br from-slate-50/50 to-transparent dark:from-slate-800/30 dark:to-transparent backdrop-blur-sm py-8 text-center">
|
||
<div className="flex flex-col items-center gap-2">
|
||
<svg className="w-8 h-8 text-slate-400 dark:text-slate-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M12 4v16m8-8H4" />
|
||
</svg>
|
||
<p className="text-sm text-slate-500 dark:text-slate-400 font-medium">暂无自定义字段</p>
|
||
<p className="text-xs text-slate-400 dark:text-slate-500">点击左侧「添加元素」开始配置</p>
|
||
</div>
|
||
</div>
|
||
) : (
|
||
<div className="grid grid-cols-12 gap-4">
|
||
{previewRenderableItems.map((item) => {
|
||
const layoutWidth = item.placeholder ? item.layoutWidth : item.field.layoutWidth || 'full'
|
||
const widthClass = layoutWidth === 'full' ? 'col-span-12' : 'col-span-12 min-[480px]:col-span-6'
|
||
if (item.placeholder) {
|
||
return <PreviewPlaceholderCard key={item.sortId} gridClassName={widthClass} />
|
||
}
|
||
return (
|
||
<PreviewSortableCard
|
||
key={item.sortId}
|
||
item={item}
|
||
isEditing={canEdit && editingFieldIndex === item.originalIndex}
|
||
isDragging={canEdit && previewActiveId === item.sortId}
|
||
onHover={canEdit ? () => onEditingFieldChange(item.originalIndex) : () => {}}
|
||
onHoverEnd={canEdit ? () => onEditingFieldChange(null) : () => {}}
|
||
onRemove={canEdit ? () => onRemoveField(item.originalIndex) : () => {}}
|
||
onClick={canEdit ? () => onScrollToField(item.originalIndex) : () => {}}
|
||
gridClassName={widthClass}
|
||
isPreviewMode={!canEdit}
|
||
/>
|
||
)
|
||
})}
|
||
</div>
|
||
)}
|
||
</SortableContext>
|
||
{typeof window !== 'undefined' &&
|
||
createPortal(
|
||
<DragOverlay
|
||
dropAnimation={{
|
||
duration: 500,
|
||
easing: 'cubic-bezier(0.34, 1.56, 0.64, 1)',
|
||
sideEffects: defaultDropAnimationSideEffects({
|
||
styles: {
|
||
active: {
|
||
opacity: '0',
|
||
transform: 'scale(0.95)',
|
||
},
|
||
},
|
||
}),
|
||
}}
|
||
style={{ cursor: 'grabbing', zIndex: 1000 }}
|
||
>
|
||
{previewActiveItem ? <PreviewDragOverlayCard item={previewActiveItem} rect={previewActiveRect} /> : null}
|
||
</DragOverlay>,
|
||
document.body,
|
||
)}
|
||
</DndContext>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
type PreviewSortableCardProps = {
|
||
item: PreviewSortableItem
|
||
isEditing: boolean
|
||
isDragging: boolean
|
||
gridClassName?: string
|
||
onHover: () => void
|
||
onHoverEnd: () => void
|
||
onRemove: () => void
|
||
onClick: () => void
|
||
isPreviewMode?: boolean
|
||
}
|
||
|
||
function PreviewSortableCard({
|
||
item,
|
||
isEditing,
|
||
isDragging,
|
||
gridClassName,
|
||
onHover,
|
||
onHoverEnd,
|
||
onRemove,
|
||
onClick,
|
||
isPreviewMode = false,
|
||
}: PreviewSortableCardProps) {
|
||
const { attributes, listeners, setNodeRef, transform, transition } = useSortable({
|
||
id: item.sortId,
|
||
disabled: isPreviewMode,
|
||
})
|
||
|
||
const style = {
|
||
transform: isDragging ? undefined : CSS.Transform.toString(transform),
|
||
transition: isDragging
|
||
? undefined
|
||
: transition ?? 'transform 0.5s cubic-bezier(0.34, 1.56, 0.64, 1), opacity 0.3s cubic-bezier(0.4, 0, 0.2, 1)',
|
||
zIndex: isDragging ? 30 : undefined,
|
||
opacity: isDragging ? 0.4 : 1,
|
||
}
|
||
|
||
const field = item.field
|
||
const widthStyle = getPreviewWidthStyle(item.field.layoutWidth)
|
||
const containerPointerEvents = isDragging ? 'none' : 'auto'
|
||
|
||
const cardStateClass = isEditing
|
||
? 'transform scale-[1.02] shadow-xl shadow-slate-200/50 dark:shadow-slate-900/50 border-2 border-slate-200/80 dark:border-slate-700/80 bg-white dark:bg-slate-800 rounded-2xl backdrop-blur-sm'
|
||
: 'hover:scale-[1.01] hover:shadow-lg hover:shadow-slate-200/50 dark:hover:shadow-slate-900/50 border border-slate-200/40 dark:border-slate-700/40 bg-white/95 dark:bg-slate-800/95 rounded-2xl backdrop-blur-sm'
|
||
|
||
return (
|
||
<div
|
||
ref={setNodeRef}
|
||
style={{
|
||
...style,
|
||
...widthStyle,
|
||
position: 'relative',
|
||
pointerEvents: containerPointerEvents,
|
||
}}
|
||
className={`group relative transition-all duration-500 ease-[cubic-bezier(0.34,1.56,0.64,1)] ${gridClassName ?? 'col-span-12'}`}
|
||
onMouseEnter={onHover}
|
||
onMouseLeave={onHoverEnd}
|
||
onClick={onClick}
|
||
>
|
||
<div className={`relative transition-all duration-300 ease-[cubic-bezier(0.34,1.56,0.64,1)] ${cardStateClass}`}>
|
||
{!isPreviewMode && (
|
||
<div className="absolute top-2 right-2 z-20 flex flex-col items-end gap-1.5 opacity-0 group-hover:opacity-100 transition-opacity duration-200">
|
||
<button
|
||
type="button"
|
||
className="w-8 h-8 rounded-full bg-white/95 dark:bg-slate-900/95 backdrop-blur-md border border-slate-200/70 dark:border-slate-700/70 shadow-lg shadow-slate-200/50 dark:shadow-slate-900/50 flex items-center justify-center cursor-grab active:cursor-grabbing hover:border-blue-400/80 dark:hover:border-blue-300/80 hover:bg-white dark:hover:bg-slate-900 hover:shadow-xl hover:scale-110 active:scale-95 transition-all duration-300 ease-[cubic-bezier(0.34,1.56,0.64,1)]"
|
||
title="拖拽调整顺序"
|
||
aria-label="拖拽调整顺序"
|
||
{...attributes}
|
||
{...listeners}
|
||
>
|
||
<svg className="w-3.5 h-3.5 text-slate-600 dark:text-slate-300" viewBox="0 0 20 20" fill="currentColor">
|
||
<path d="M7 5a1 1 0 11-2 0 1 1 0 012 0zm4 0a1 1 0 11-2 0 1 1 0 012 0zm4 0a1 1 0 11-2 0 1 1 0 012 0zM7 10a1 1 0 11-2 0 1 1 0 012 0zm4 0a1 1 0 11-2 0 1 1 0 012 0zm4 0a1 1 0 11-2 0 1 1 0 012 0zM7 15a1 1 0 11-2 0 1 1 0 012 0zm4 0a1 1 0 11-2 0 1 1 0 012 0zm4 0a1 1 0 11-2 0 1 1 0 012 0z" />
|
||
</svg>
|
||
</button>
|
||
<button
|
||
onClick={(e) => {
|
||
e.stopPropagation()
|
||
onRemove()
|
||
}}
|
||
className="w-7 h-7 bg-gradient-to-br from-red-500 to-red-600 text-white rounded-full flex items-center justify-center hover:from-red-600 hover:to-red-700 transition-all duration-300 ease-[cubic-bezier(0.34,1.56,0.64,1)] hover:scale-110 active:scale-95 shadow-md shadow-red-500/30 hover:shadow-lg hover:shadow-red-500/40"
|
||
title="删除此字段"
|
||
>
|
||
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2.5} d="M6 18L18 6M6 6l12 12" />
|
||
</svg>
|
||
</button>
|
||
</div>
|
||
)}
|
||
<PreviewFieldContent field={field} />
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
function PreviewFieldContent({ field }: { field: FieldForm }) {
|
||
const baseInputClass =
|
||
'w-full px-4 py-2.5 text-sm rounded-2xl outline-none transition-all duration-300 ease-[cubic-bezier(0.4,0,0.2,1)] border bg-white/90 dark:bg-slate-800/80 dark:text-white border-slate-200/70 dark:border-slate-700/60 shadow-[inset_0_1px_0_rgba(255,255,255,0.75)] dark:shadow-none focus-visible:ring-2 focus-visible:ring-blue-400/60 focus-visible:border-blue-400/60'
|
||
const requiredInputClass = field.required
|
||
? ' bg-gradient-to-br from-white via-blue-50/90 to-blue-100/60 dark:from-slate-800/80 dark:via-blue-900/30 dark:to-blue-900/20 border-blue-200/80 dark:border-blue-500/50 shadow-[0_18px_35px_-25px_rgba(37,99,235,1)]'
|
||
: ' bg-white/90 dark:bg-slate-800/80'
|
||
|
||
const optionList = (() => {
|
||
if (!field.optionsStatic) return []
|
||
const raw = field.optionsStatic.trim()
|
||
if (!raw) return []
|
||
if (raw.startsWith('[') || raw.startsWith('{')) {
|
||
try {
|
||
const parsed = JSON.parse(raw)
|
||
if (Array.isArray(parsed)) return parsed.map((item) => String(item))
|
||
} catch {
|
||
// ignore
|
||
}
|
||
}
|
||
return raw
|
||
.split('\n')
|
||
.map((opt) => opt.trim())
|
||
.filter(Boolean)
|
||
})()
|
||
|
||
return (
|
||
<div className="pointer-events-none space-y-2.5">
|
||
<div className="flex items-center justify-between gap-3">
|
||
<span className="text-xs font-semibold text-slate-800 dark:text-slate-200 tracking-tight">{field.label || field.key || '未命名字段'}</span>
|
||
{field.required && (
|
||
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-[10px] font-semibold uppercase tracking-[0.18em] text-white bg-gradient-to-r from-purple-500 to-indigo-600 shadow-[0_8px_16px_rgba(79,70,229,0.35)]">
|
||
必填
|
||
</span>
|
||
)}
|
||
</div>
|
||
{field.type === 'text' && <input type="text" placeholder={`请输入${field.label || field.key}`} className={`${baseInputClass}${requiredInputClass}`} disabled />}
|
||
{field.type === 'textarea' && (
|
||
<textarea rows={2} placeholder={`请输入${field.label || field.key}`} className={`${baseInputClass}${requiredInputClass} resize-none`} disabled />
|
||
)}
|
||
{field.type === 'number' && <input type="number" placeholder={`请输入${field.label || field.key}`} className={`${baseInputClass}${requiredInputClass}`} disabled />}
|
||
{field.type === 'select' && (
|
||
<div className="space-y-1.5">
|
||
<select className={`${baseInputClass}${requiredInputClass}`} disabled>
|
||
<option value="">请选择{field.label || field.key}</option>
|
||
{optionList.map((opt, idx) => (
|
||
<option key={`${field.key}-opt-${idx}`} value={opt}>
|
||
{opt}
|
||
</option>
|
||
))}
|
||
</select>
|
||
{optionList.length === 0 && <p className="text-[10px] text-slate-400 dark:text-slate-500">暂无下拉选项</p>}
|
||
</div>
|
||
)}
|
||
{field.type === 'date' && <input type="date" className={`${baseInputClass}${requiredInputClass}`} disabled />}
|
||
{field.type === 'uppercase' && (
|
||
<input type="text" placeholder={`请输入${field.label || field.key}(仅大写字母)`} className={`${baseInputClass}${requiredInputClass} uppercase`} disabled />
|
||
)}
|
||
{field.type === 'checkbox' && (
|
||
<label className="flex items-center gap-2 cursor-default select-none">
|
||
<input type="checkbox" className="w-4 h-4 rounded border-slate-300 dark:border-slate-600 text-blue-600 focus:ring-2 focus:ring-blue-500" disabled />
|
||
<span className="text-xs text-slate-700 dark:text-slate-300">选择{field.label || field.key}</span>
|
||
</label>
|
||
)}
|
||
{field.type === 'radio' && (
|
||
<div className="space-y-1.5">
|
||
{optionList.map((opt, idx) => (
|
||
<label key={`${field.key}-radio-${idx}`} className="flex items-center gap-2 cursor-default select-none">
|
||
<input type="radio" name={`preview-${field.key}`} value={opt} className="w-3.5 h-3.5 text-blue-600 focus:ring-2 focus:ring-blue-500" disabled />
|
||
<span className="text-xs text-slate-700 dark:text-slate-300">{opt}</span>
|
||
</label>
|
||
))}
|
||
{optionList.length === 0 && <p className="text-[10px] text-slate-400 dark:text-slate-500">暂无单选项</p>}
|
||
</div>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
function PreviewDragOverlayCard({
|
||
item,
|
||
rect,
|
||
}: {
|
||
item: PreviewSortableItem
|
||
rect: { width: number; height: number } | null
|
||
}) {
|
||
const widthValue = getPreviewWidthStyleValue(item.field.layoutWidth)
|
||
return (
|
||
<div
|
||
className="pointer-events-none"
|
||
style={{
|
||
width: rect?.width ?? widthValue,
|
||
height: rect?.height,
|
||
maxWidth: '90vw',
|
||
transform: 'none',
|
||
}}
|
||
>
|
||
<div className="rounded-2xl border-2 border-blue-500/60 dark:border-blue-400/60 bg-white/98 dark:bg-slate-800/98 backdrop-blur-xl shadow-2xl shadow-blue-500/30 dark:shadow-blue-900/50 px-4 py-3 transform rotate-[1.5deg] scale-[1.08] transition-all duration-200 ease-out">
|
||
<PreviewFieldContent field={item.field} />
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
function PreviewPlaceholderCard({ gridClassName }: { gridClassName: string }) {
|
||
return (
|
||
<div className={gridClassName}>
|
||
<div className="h-full min-h-[88px] rounded-2xl border-2 border-dashed border-blue-500 dark:border-blue-400 bg-gradient-to-br from-blue-50/90 to-blue-100/70 dark:from-blue-900/40 dark:to-blue-800/30 flex items-center justify-center text-blue-600 dark:text-blue-300 text-xs font-semibold tracking-wide pointer-events-none shadow-xl shadow-blue-200/60 dark:shadow-blue-900/60 animate-pulse">
|
||
<div className="flex flex-col items-center gap-2 animate-bounce">
|
||
<svg className="w-6 h-6 text-blue-500 dark:text-blue-400 animate-pulse" viewBox="0 0 24 24" fill="none" stroke="currentColor">
|
||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 12h14M12 5l7 7-7 7" />
|
||
</svg>
|
||
<span className="opacity-80 animate-pulse">松开即可放置</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
|