WuYongJie e4dcb6260a
Some checks failed
CI Check / check (push) Has been cancelled
feat: Admin页面重构 & UI三层架构系统
- 创建统一UI三层架构系统(基础层、颜色层、组合层)
- 拆分Admin页面为独立Tab组件(MonitorTab、UsersTab、DepartmentsTab等)
- 提取公共逻辑到Hooks(useToast、useDataFetch、useUserOperations等)
- 提取工具函数(validators、formatters、api等)
- 创建统一弹窗组件(Modal)和useModal Hook
- 添加错误边界和统一状态组件
- 完善类型定义和可访问性支持
- 更新文档(优化记录、优化计划、更新日志)
2025-11-22 15:17:10 +08:00

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