Spaces:
Sleeping
Sleeping
| import { useState, useMemo } from 'react'; | |
| import { | |
| Plus, | |
| Trash2, | |
| Settings2, | |
| Layout, | |
| Type, | |
| Square, | |
| CheckSquare, | |
| Smartphone, | |
| Monitor, | |
| Eye, | |
| Code, | |
| Save, | |
| Download | |
| } from 'lucide-react'; | |
| import { v4 as uuidv4 } from 'uuid'; | |
| import { clsx, type ClassValue } from 'clsx'; | |
| import { twMerge } from 'tailwind-merge'; | |
| // 样式合并工具函数 | |
| function cn(...inputs: ClassValue[]) { | |
| return twMerge(clsx(inputs)); | |
| } | |
| // 组件类型定义 | |
| type ComponentType = 'text' | 'button' | 'input' | 'card' | 'checkbox'; | |
| interface VisualComponent { | |
| id: string; | |
| type: ComponentType; | |
| props: Record<string, any>; | |
| } | |
| // 可选组件库 | |
| const COMPONENT_LIBRARY = [ | |
| { type: 'text' as const, label: '文本', icon: Type, defaultProps: { text: '点击编辑文本', fontSize: '16px', color: '#000000' } }, | |
| { type: 'button' as const, label: '按钮', icon: Square, defaultProps: { label: '点击按钮', variant: 'primary', width: 'auto' } }, | |
| { type: 'input' as const, label: '输入框', icon: Type, defaultProps: { placeholder: '请输入内容...', label: '表单字段' } }, | |
| { type: 'card' as const, label: '卡片容器', icon: Layout, defaultProps: { title: '卡片标题', content: '这里是卡片内容区域' } }, | |
| { type: 'checkbox' as const, label: '复选框', icon: CheckSquare, defaultProps: { label: '选项名称', checked: false } }, | |
| ]; | |
| /** | |
| * 极简可视化低代码平台 - 核心入口组件 | |
| * 支持组件库拖拽、画布渲染、属性实时编辑及多端预览 | |
| */ | |
| export default function App() { | |
| // --- 状态管理 --- | |
| const [components, setComponents] = useState<VisualComponent[]>([]); // 画布上的组件列表 | |
| const [selectedId, setSelectedId] = useState<string | null>(null); // 当前选中的组件 ID | |
| const [viewMode, setViewMode] = useState<'desktop' | 'mobile'>('desktop'); // 预览模式:桌面/移动端 | |
| const [isPreview, setIsPreview] = useState(false); // 是否处于预览模式 | |
| // 获取当前选中的组件对象 | |
| const selectedComponent = useMemo(() => | |
| components.find(c => c.id === selectedId), | |
| [components, selectedId] | |
| ); | |
| // 添加新组件到画布 | |
| const addComponent = (type: ComponentType, defaultProps: any) => { | |
| const newComp: VisualComponent = { | |
| id: uuidv4(), | |
| type, | |
| props: { ...defaultProps } | |
| }; | |
| setComponents([...components, newComp]); | |
| setSelectedId(newComp.id); | |
| }; | |
| // 从画布中移除组件 | |
| const removeComponent = (id: string) => { | |
| setComponents(components.filter(c => c.id !== id)); | |
| if (selectedId === id) setSelectedId(null); | |
| }; | |
| // 更新选中组件的属性 | |
| const updateProps = (id: string, newProps: any) => { | |
| setComponents(components.map(c => | |
| c.id === id ? { ...c, props: { ...c.props, ...newProps } } : c | |
| )); | |
| }; | |
| // 渲染单个可视化组件 | |
| const renderComponent = (comp: VisualComponent, index: number) => { | |
| const isSelected = selectedId === comp.id; | |
| const wrapperClass = cn( | |
| "relative group cursor-pointer border-2 border-transparent transition-all", | |
| !isPreview && isSelected && "border-blue-500 rounded-lg", | |
| !isPreview && !isSelected && "hover:border-dashed hover:border-gray-300 rounded-lg" | |
| ); | |
| const commonProps = { | |
| onClick: (e: React.MouseEvent) => { | |
| if (isPreview) return; | |
| e.stopPropagation(); | |
| setSelectedId(comp.id); | |
| } | |
| }; | |
| let element; | |
| switch (comp.type) { | |
| case 'text': | |
| element = ( | |
| <div style={{ fontSize: comp.props.fontSize, color: comp.props.color }}> | |
| {comp.props.text} | |
| </div> | |
| ); | |
| break; | |
| case 'button': | |
| element = ( | |
| <button className={cn( | |
| "px-4 py-2 rounded font-medium transition-colors", | |
| comp.props.variant === 'primary' ? "bg-blue-600 text-white" : "bg-gray-200 text-gray-800", | |
| comp.props.width === 'full' ? "w-full" : "w-auto" | |
| )}> | |
| {comp.props.label} | |
| </button> | |
| ); | |
| break; | |
| case 'input': | |
| element = ( | |
| <div className="flex flex-col gap-1"> | |
| <label className="text-sm font-medium text-gray-700">{comp.props.label}</label> | |
| <input | |
| type="text" | |
| placeholder={comp.props.placeholder} | |
| className="px-3 py-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-blue-500 outline-none" | |
| disabled={!isPreview} | |
| /> | |
| </div> | |
| ); | |
| break; | |
| case 'card': | |
| element = ( | |
| <div className="bg-white p-4 rounded-xl shadow-sm border border-gray-100"> | |
| <h3 className="text-lg font-bold mb-2">{comp.props.title}</h3> | |
| <p className="text-gray-600">{comp.props.content}</p> | |
| </div> | |
| ); | |
| break; | |
| case 'checkbox': | |
| element = ( | |
| <label className="flex items-center gap-2 cursor-pointer"> | |
| <input type="checkbox" checked={comp.props.checked} readOnly={!isPreview} className="w-4 h-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500" /> | |
| <span className="text-gray-700">{comp.props.label}</span> | |
| </label> | |
| ); | |
| break; | |
| } | |
| return ( | |
| <div key={comp.id} className={wrapperClass} {...commonProps}> | |
| {element} | |
| {!isPreview && isSelected && ( | |
| <button | |
| onClick={(e) => { e.stopPropagation(); removeComponent(comp.id); }} | |
| className="absolute -top-3 -right-3 bg-red-500 text-white p-1 rounded-full shadow-lg opacity-0 group-hover:opacity-100 transition-opacity" | |
| > | |
| <Trash2 size={14} /> | |
| </button> | |
| )} | |
| </div> | |
| ); | |
| }; | |
| return ( | |
| <div className="flex h-screen bg-gray-50 text-gray-900 font-sans overflow-hidden"> | |
| {/* 顶部工具栏 */} | |
| <div className="absolute top-0 left-0 right-0 h-14 bg-white border-b border-gray-200 flex items-center justify-between px-6 z-50 shadow-sm"> | |
| <div className="flex items-center gap-2"> | |
| <div className="bg-blue-600 p-1.5 rounded-lg"> | |
| <Code className="text-white" size={20} /> | |
| </div> | |
| <span className="font-bold text-lg tracking-tight">极简低代码平台</span> | |
| </div> | |
| <div className="flex items-center gap-4 bg-gray-100 p-1 rounded-lg"> | |
| <button | |
| onClick={() => setViewMode('desktop')} | |
| className={cn("p-1.5 rounded-md transition-all", viewMode === 'desktop' ? "bg-white shadow-sm text-blue-600" : "text-gray-500")} | |
| > | |
| <Monitor size={18} /> | |
| </button> | |
| <button | |
| onClick={() => setViewMode('mobile')} | |
| className={cn("p-1.5 rounded-md transition-all", viewMode === 'mobile' ? "bg-white shadow-sm text-blue-600" : "text-gray-500")} | |
| > | |
| <Smartphone size={18} /> | |
| </button> | |
| </div> | |
| <div className="flex items-center gap-3"> | |
| <button | |
| onClick={() => setIsPreview(!isPreview)} | |
| className={cn( | |
| "flex items-center gap-2 px-4 py-1.5 rounded-lg font-medium transition-all", | |
| isPreview ? "bg-orange-100 text-orange-600" : "bg-blue-50 text-blue-600 hover:bg-blue-100" | |
| )} | |
| > | |
| {isPreview ? <Save size={18} /> : <Eye size={18} />} | |
| {isPreview ? "退出预览" : "预览"} | |
| </button> | |
| <button className="bg-blue-600 text-white px-4 py-1.5 rounded-lg font-medium hover:bg-blue-700 transition-all flex items-center gap-2"> | |
| <Download size={18} /> | |
| 发布 | |
| </button> | |
| </div> | |
| </div> | |
| {/* 左侧组件库 */} | |
| {!isPreview && ( | |
| <aside className="w-72 bg-white border-r border-gray-200 mt-14 flex flex-col z-40"> | |
| <div className="p-4 border-b border-gray-100"> | |
| <h2 className="text-sm font-semibold text-gray-400 uppercase tracking-wider">组件库</h2> | |
| </div> | |
| <div className="flex-1 overflow-y-auto p-4 space-y-3"> | |
| {COMPONENT_LIBRARY.map((item) => ( | |
| <button | |
| key={item.type} | |
| onClick={() => addComponent(item.type, item.defaultProps)} | |
| className="w-full flex items-center gap-3 p-3 bg-gray-50 hover:bg-blue-50 border border-gray-100 hover:border-blue-200 rounded-xl transition-all group text-left" | |
| > | |
| <div className="p-2 bg-white rounded-lg shadow-sm group-hover:text-blue-600 transition-colors"> | |
| <item.icon size={18} /> | |
| </div> | |
| <span className="font-medium text-gray-700 group-hover:text-blue-600">{item.label}</span> | |
| <Plus size={16} className="ml-auto text-gray-300 group-hover:text-blue-400" /> | |
| </button> | |
| ))} | |
| </div> | |
| </aside> | |
| )} | |
| {/* 中间画布 */} | |
| <main className={cn( | |
| "flex-1 mt-14 overflow-auto p-8 flex justify-center transition-all duration-300 bg-gray-50", | |
| isPreview && "p-0 bg-white" | |
| )}> | |
| <div | |
| className={cn( | |
| "bg-white shadow-2xl transition-all duration-300 border border-gray-200 min-h-full", | |
| viewMode === 'mobile' ? "w-[375px] rounded-[40px] border-[12px] border-gray-800 p-8 my-4" : "w-full max-w-4xl rounded-xl p-10", | |
| isPreview && viewMode === 'desktop' && "max-w-none w-full border-none shadow-none rounded-none", | |
| isPreview && viewMode === 'mobile' && "my-8" | |
| )} | |
| onClick={() => setSelectedId(null)} | |
| > | |
| {components.length === 0 ? ( | |
| <div className="h-full flex flex-col items-center justify-center text-gray-400 gap-4 mt-20"> | |
| <div className="bg-gray-100 p-6 rounded-full"> | |
| <Layout size={48} /> | |
| </div> | |
| <p className="text-lg">从左侧拖拽或点击组件开始设计</p> | |
| </div> | |
| ) : ( | |
| <div className="space-y-6"> | |
| {components.map((comp, index) => renderComponent(comp, index))} | |
| </div> | |
| )} | |
| </div> | |
| </main> | |
| {/* 右侧属性面板 */} | |
| {!isPreview && ( | |
| <aside className="w-80 bg-white border-l border-gray-200 mt-14 flex flex-col z-40"> | |
| <div className="p-4 border-b border-gray-100 flex items-center gap-2"> | |
| <Settings2 size={18} className="text-blue-600" /> | |
| <h2 className="text-sm font-semibold text-gray-700">属性配置</h2> | |
| </div> | |
| <div className="flex-1 overflow-y-auto p-5"> | |
| {selectedComponent ? ( | |
| <div className="space-y-6"> | |
| <div className="bg-blue-50 p-3 rounded-lg border border-blue-100"> | |
| <span className="text-xs font-bold text-blue-600 uppercase">当前组件: {selectedComponent.type}</span> | |
| </div> | |
| {/* 根据组件类型显示不同的编辑项 */} | |
| <div className="space-y-4"> | |
| {Object.entries(selectedComponent.props).map(([key, value]) => ( | |
| <div key={key} className="space-y-1.5"> | |
| <label className="text-xs font-medium text-gray-500 uppercase">{key === 'text' ? '文本内容' : key === 'label' ? '标签' : key === 'fontSize' ? '字号' : key === 'color' ? '颜色' : key === 'placeholder' ? '提示词' : key === 'title' ? '标题' : key === 'content' ? '正文' : key === 'variant' ? '样式' : key === 'width' ? '宽度' : key}</label> | |
| {typeof value === 'string' && (key !== 'variant' && key !== 'width') ? ( | |
| <input | |
| type={key === 'color' ? 'color' : 'text'} | |
| value={value} | |
| onChange={(e) => updateProps(selectedComponent.id, { [key]: e.target.value })} | |
| className="w-full px-3 py-2 bg-gray-50 border border-gray-200 rounded-lg focus:ring-2 focus:ring-blue-500 outline-none text-sm" | |
| /> | |
| ) : key === 'variant' ? ( | |
| <select | |
| value={value} | |
| onChange={(e) => updateProps(selectedComponent.id, { [key]: e.target.value })} | |
| className="w-full px-3 py-2 bg-gray-50 border border-gray-200 rounded-lg outline-none text-sm" | |
| > | |
| <option value="primary">主按钮</option> | |
| <option value="secondary">次按钮</option> | |
| </select> | |
| ) : key === 'width' ? ( | |
| <select | |
| value={value} | |
| onChange={(e) => updateProps(selectedComponent.id, { [key]: e.target.value })} | |
| className="w-full px-3 py-2 bg-gray-50 border border-gray-200 rounded-lg outline-none text-sm" | |
| > | |
| <option value="auto">自适应</option> | |
| <option value="full">撑满</option> | |
| </select> | |
| ) : null} | |
| </div> | |
| ))} | |
| </div> | |
| </div> | |
| ) : ( | |
| <div className="h-full flex flex-col items-center justify-center text-gray-400 text-sm gap-2"> | |
| <Smartphone size={32} strokeWidth={1.5} /> | |
| <p>选中画布上的组件进行编辑</p> | |
| </div> | |
| )} | |
| </div> | |
| </aside> | |
| )} | |
| </div> | |
| ); | |
| } | |