2026-05-25 14:52:24 +08:00

686 lines
28 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 { 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>
)
}