'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 { cn } from '@/lib/ui-styles' 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(() => { 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() previewSortableFields.forEach((item) => { map.set(item.sortId, item.originalIndex) }) return map }, [previewSortableFields]) const previewSortableMap = useMemo(() => { const map = new Map() previewSortableFields.forEach((item) => { map.set(item.sortId, item) }) return map }, [previewSortableFields]) const previewDefaultOrder = useMemo( () => previewSortableFields.map((item) => item.sortId), [previewSortableFields], ) const [previewOrder, setPreviewOrder] = useState(previewDefaultOrder) const [previewActiveId, setPreviewActiveId] = useState(null) const [previewOverId, setPreviewOverId] = useState(null) const [previewActiveRect, setPreviewActiveRect] = useState<{ width: number; height: number } | null>(null) const [previewPlaceholderVisible, setPreviewPlaceholderVisible] = useState(false) const [previewPlaceholderIndex, setPreviewPlaceholderIndex] = useState(null) const pendingPlaceholderIndexRef = useRef(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(() => { 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 (
{!isPreviewMode && (

预览区

查看上传面板的实际效果

右侧只读,点击字段可定位到左侧配置。

)} {isPreviewMode && (

模板预览

查看模板在上传页中的效果

)}

点击或拖拽上传预览图

支持 JPG、PNG、WebP

点击或拖拽上传素材文件

支持 ZIP、RAR 等压缩格式

{orderedPreviewItems.length === 0 ? (

暂无自定义字段

点击左侧「添加元素」开始配置

) : (
{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 } return ( onEditingFieldChange(item.originalIndex) : () => {}} onHoverEnd={canEdit ? () => onEditingFieldChange(null) : () => {}} onRemove={canEdit ? () => onRemoveField(item.originalIndex) : () => {}} onClick={canEdit ? () => onScrollToField(item.originalIndex) : () => {}} gridClassName={widthClass} isPreviewMode={!canEdit} /> ) })}
)}
{typeof window !== 'undefined' && createPortal( {previewActiveItem ? : null} , document.body, )}
) } 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 ? 'border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-800 rounded-lg' : 'border border-slate-200/60 dark:border-slate-700/60 bg-white dark:bg-slate-800 rounded-lg hover:border-slate-300 dark:hover:border-slate-600' return (
{!isPreviewMode && (
)}
) } 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 (
{field.label || field.key || '未命名字段'} {field.required && ( 必填 )}
{field.type === 'text' && } {field.type === 'textarea' && (