686 lines
28 KiB
TypeScript
686 lines
28 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 ${
|
||
isPreviewMode ? 'w-full max-w-[520px] mx-auto' : 'w-full lg:w-1/2'
|
||
}`}
|
||
>
|
||
<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-lg font-semibold text-slate-900 dark:text-white mb-1.5">预览区</h4>
|
||
<p className="text-sm text-slate-500 dark:text-slate-400">查看上传面板的实际效果</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 border-dashed border-slate-300 dark:border-slate-600 rounded-lg p-4 bg-slate-50/30 dark:bg-slate-800/30'
|
||
: ''
|
||
}
|
||
>
|
||
<div
|
||
className={`bg-white dark:bg-slate-800 rounded-lg shadow-sm border border-slate-200 dark:border-slate-700 space-y-5 ${
|
||
isPreviewMode ? 'p-5 border border-slate-300 dark:border-slate-600' : '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 border-dashed border-slate-300/60 dark:border-slate-600/60 rounded-lg p-4 transition-colors hover:border-slate-400 dark:hover:border-slate-500 hover:bg-slate-50/50 dark:hover:bg-slate-700/30">
|
||
<div className="flex items-center justify-center space-x-3">
|
||
<div className="w-16 h-16 rounded-xl bg-slate-100 dark:bg-slate-800 flex items-center justify-center flex-shrink-0 border border-slate-200 dark:border-slate-700">
|
||
<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-lg p-4 transition-colors hover:border-slate-400 dark:hover:border-slate-500 hover:bg-slate-50/50 dark:hover:bg-slate-700/30">
|
||
<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-lg border-2 border-dashed border-slate-200 dark:border-slate-700 bg-slate-50/70 dark:bg-slate-800/40 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: 180,
|
||
easing: 'ease-out',
|
||
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.2s ease-out, opacity 0.2s ease-out',
|
||
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
|
||
? 'shadow-sm border border-slate-200/80 dark:border-slate-700/80 bg-white dark:bg-slate-800 rounded-lg'
|
||
: 'hover:shadow-sm border border-slate-200/40 dark:border-slate-700/40 bg-white dark:bg-slate-800 rounded-lg'
|
||
|
||
return (
|
||
<div
|
||
ref={setNodeRef}
|
||
style={{
|
||
...style,
|
||
...widthStyle,
|
||
position: 'relative',
|
||
pointerEvents: containerPointerEvents,
|
||
}}
|
||
className={`group relative transition-transform duration-200 ease-out ${gridClassName ?? 'col-span-12'}`}
|
||
onMouseEnter={onHover}
|
||
onMouseLeave={onHoverEnd}
|
||
onClick={onClick}
|
||
>
|
||
<div className={`relative transition-all duration-200 ease-out ${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 dark:bg-slate-900 border border-slate-200 dark:border-slate-700 flex items-center justify-center cursor-grab active:cursor-grabbing hover:border-slate-400 dark:hover:border-slate-500 hover:bg-slate-50 dark:hover:bg-slate-800 transition-colors"
|
||
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-red-500 text-white rounded-full flex items-center justify-center hover:bg-red-600 transition-colors"
|
||
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-lg outline-none transition-colors border bg-white/90 dark:bg-slate-800/80 dark:text-white border-slate-200/70 dark:border-slate-700/60 focus-visible:ring-2 focus-visible:ring-slate-300 dark:focus-visible:ring-slate-600 focus-visible:border-slate-400 dark:focus-visible:border-slate-500'
|
||
const requiredInputClass = field.required
|
||
? ' bg-slate-50 dark:bg-slate-800 border-slate-300 dark:border-slate-600'
|
||
: ' 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-md border border-slate-200 dark:border-slate-700 bg-slate-100 dark:bg-slate-800 text-[10px] font-medium text-slate-600 dark:text-slate-300">
|
||
必填
|
||
</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-lg border border-slate-300 dark:border-slate-700 bg-white dark:bg-slate-800 shadow-sm px-4 py-3">
|
||
<PreviewFieldContent field={item.field} />
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
function PreviewPlaceholderCard({ gridClassName }: { gridClassName: string }) {
|
||
return (
|
||
<div className={gridClassName}>
|
||
<div className="h-full min-h-[88px] rounded-lg border border-dashed border-slate-300 dark:border-slate-700 bg-slate-50 dark:bg-slate-800/60 flex items-center justify-center text-slate-600 dark:text-slate-300 text-xs font-medium pointer-events-none">
|
||
<div className="flex flex-col items-center gap-2">
|
||
<svg className="w-6 h-6 text-slate-400 dark:text-slate-500" 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>松开后放置</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
|