feat: 初始化低代码平台前端Demo项目

- 实现拖拽组件库(输入框、下拉选择、表格、按钮等12种组件)
- 实现画布拖放和组件排序功能
- 实现属性配置面板(基础属性和样式配置)
- 实现用户管理CRUD完整功能(增删改查、搜索筛选)
- 支持中英文国际化切换
- 支持设计器/预览双模式切换
- 使用React 18 + TypeScript + Vite + Ant Design + dnd-kit + Zustand技术栈
This commit is contained in:
feeling 2026-02-24 08:03:13 +08:00
commit 24528db0f7
23 changed files with 7264 additions and 0 deletions

26
.gitignore vendored Normal file
View File

@ -0,0 +1,26 @@
# Dependencies
node_modules/
# Build output
dist/
# IDE
.vscode/
.idea/
# Environment
.env
.env.local
.env.*.local
# Logs
*.log
npm-debug.log*
# OS
.DS_Store
Thumbs.db
# Trae
.trae/
node_modules

13
index.html Normal file
View File

@ -0,0 +1,13 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>低代码平台 - 用户管理系统设计器</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

4873
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

41
package.json Normal file
View File

@ -0,0 +1,41 @@
{
"name": "lowcode-demo",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "eslint .",
"preview": "vite preview"
},
"dependencies": {
"@dnd-kit/core": "^6.1.0",
"@dnd-kit/sortable": "^8.0.0",
"@dnd-kit/utilities": "^3.2.2",
"antd": "^5.21.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^6.26.0",
"uuid": "^10.0.0",
"zustand": "^4.5.5"
},
"devDependencies": {
"@eslint/js": "^9.9.0",
"@types/node": "^25.3.0",
"@types/react": "^18.3.3",
"@types/react-dom": "^18.3.0",
"@types/uuid": "^10.0.0",
"@vitejs/plugin-react": "^4.3.1",
"autoprefixer": "^10.4.20",
"eslint": "^9.9.0",
"eslint-plugin-react-hooks": "^5.1.0-rc.0",
"eslint-plugin-react-refresh": "^0.4.9",
"globals": "^15.9.0",
"postcss": "^8.4.45",
"tailwindcss": "^3.4.10",
"typescript": "^5.5.3",
"typescript-eslint": "^8.0.1",
"vite": "^5.4.1"
}
}

6
postcss.config.js Normal file
View File

@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

26
src/App.tsx Normal file
View File

@ -0,0 +1,26 @@
import React from 'react';
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
import { ConfigProvider } from 'antd';
import zhCN from 'antd/locale/zh_CN';
import enUS from 'antd/locale/en_US';
import { DesignerPage } from '@/pages/Designer';
import { PreviewPage } from '@/pages/Preview';
import { useDesignerStore } from '@/store';
const App: React.FC = () => {
const language = useDesignerStore((state) => state.language);
return (
<ConfigProvider locale={language === 'zh' ? zhCN : enUS}>
<BrowserRouter>
<Routes>
<Route path="/designer" element={<DesignerPage />} />
<Route path="/preview" element={<PreviewPage />} />
<Route path="/" element={<Navigate to="/designer" replace />} />
</Routes>
</BrowserRouter>
</ConfigProvider>
);
};
export default App;

View File

@ -0,0 +1,55 @@
import React from 'react';
import { useDroppable } from '@dnd-kit/core';
import { SortableContext, verticalListSortingStrategy } from '@dnd-kit/sortable';
import { ComponentRenderer } from './ComponentRenderer';
import { useDesignerStore } from '@/store';
import { t } from '@/locales';
import { DesignerComponent } from '@/types';
interface CanvasAreaProps {
components: DesignerComponent[];
}
export const CanvasArea: React.FC<CanvasAreaProps> = ({ components }) => {
const language = useDesignerStore((state) => state.language);
const selectComponent = useDesignerStore((state) => state.selectComponent);
const { setNodeRef, isOver } = useDroppable({
id: 'canvas-droppable'
});
const handleCanvasClick = () => {
selectComponent(null);
};
return (
<div className="canvas-content" onClick={handleCanvasClick}>
<div
ref={setNodeRef}
className={`canvas-wrapper ${isOver ? 'drag-over' : ''}`}
style={{ minHeight: 500 }}
>
{components.length === 0 ? (
<div className="empty-canvas">
<div className="empty-canvas-icon">📦</div>
<div className="text-lg font-medium mb-2">{t('dragTip', language)}</div>
<div className="text-sm text-gray-400">
{language === 'zh'
? '支持表单、表格、按钮等多种组件'
: 'Support forms, tables, buttons and more'}
</div>
</div>
) : (
<SortableContext
items={components.map((c) => c.id)}
strategy={verticalListSortingStrategy}
>
{components.map((component) => (
<ComponentRenderer key={component.id} component={component} />
))}
</SortableContext>
)}
</div>
</div>
);
};

View File

@ -0,0 +1,291 @@
import React from 'react';
import { Input, InputNumber, Select, DatePicker, Button, Checkbox, Radio, Table, Typography, Form } from 'antd';
import { DesignerComponent } from '@/types';
import { useDesignerStore } from '@/store';
import { t } from '@/locales';
const { Text, Title } = Typography;
const { TextArea } = Input;
interface ComponentRendererProps {
component: DesignerComponent;
isPreview?: boolean;
}
export const ComponentRenderer: React.FC<ComponentRendererProps> = ({
component,
isPreview = false
}) => {
const language = useDesignerStore((state) => state.language);
const users = useDesignerStore((state) => state.users);
const selectedComponentId = useDesignerStore((state) => state.selectedComponentId);
const selectComponent = useDesignerStore((state) => state.selectComponent);
const deleteComponent = useDesignerStore((state) => state.deleteComponent);
const updateUser = useDesignerStore((state) => state.updateUser);
const deleteUser = useDesignerStore((state) => state.deleteUser);
const addUser = useDesignerStore((state) => state.addUser);
const { type, props, style } = component;
const isSelected = selectedComponentId === component.id && !isPreview;
const handleClick = (e: React.MouseEvent) => {
if (!isPreview) {
e.stopPropagation();
selectComponent(component.id);
}
};
const handleDelete = (e: React.MouseEvent) => {
e.stopPropagation();
deleteComponent(component.id);
};
const renderComponent = () => {
switch (type) {
case 'input':
return (
<div style={style}>
<div className="mb-1 text-sm text-gray-600">{props.label as string}</div>
<Input
placeholder={props.placeholder as string}
style={{ width: '100%' }}
/>
</div>
);
case 'textarea':
return (
<div style={style}>
<div className="mb-1 text-sm text-gray-600">{props.label as string}</div>
<TextArea
placeholder={props.placeholder as string}
rows={props.rows as number || 4}
style={{ width: '100%' }}
/>
</div>
);
case 'number':
return (
<div style={style}>
<div className="mb-1 text-sm text-gray-600">{props.label as string}</div>
<InputNumber
placeholder={props.placeholder as string}
min={props.min as number}
max={props.max as number}
style={{ width: '100%' }}
/>
</div>
);
case 'select':
return (
<div style={style}>
<div className="mb-1 text-sm text-gray-600">{props.label as string}</div>
<Select
placeholder={props.placeholder as string}
style={{ width: '100%' }}
options={props.options as { label: string; value: string }[] || []}
/>
</div>
);
case 'datePicker':
return (
<div style={style}>
<div className="mb-1 text-sm text-gray-600">{props.label as string}</div>
<DatePicker style={{ width: '100%' }} placeholder={props.placeholder as string} />
</div>
);
case 'checkbox':
return (
<div style={style}>
<Checkbox>{props.label as string}</Checkbox>
</div>
);
case 'radio':
return (
<div style={style}>
<div className="mb-1 text-sm text-gray-600">{props.label as string}</div>
<Radio.Group>
{(props.options as { label: string; value: string }[] || []).map((opt, idx) => (
<Radio key={idx} value={opt.value}>{opt.label}</Radio>
))}
</Radio.Group>
</div>
);
case 'button':
return (
<Button
type={props.buttonType as 'primary' | 'default' | 'dashed' | 'text' | 'link'}
style={style}
>
{props.text as string}
</Button>
);
case 'search':
return (
<Input.Search
placeholder={props.placeholder as string}
style={style}
enterButton={t('search', language)}
/>
);
case 'text':
return (
<Text style={{ fontSize: props.fontSize as number, ...style }}>
{props.content as string}
</Text>
);
case 'table':
const columns = [
{
title: t('userName', language),
dataIndex: 'name',
key: 'name',
width: 120
},
{
title: t('email', language),
dataIndex: 'email',
key: 'email',
width: 200
},
{
title: t('phone', language),
dataIndex: 'phone',
key: 'phone',
width: 130
},
{
title: t('department', language),
dataIndex: 'department',
key: 'department',
width: 100
},
{
title: t('status', language),
dataIndex: 'status',
key: 'status',
width: 80,
render: (status: string) => (
<span className={`px-2 py-1 rounded-full text-xs ${
status === 'active' ? 'bg-green-100 text-green-700' : 'bg-red-100 text-red-700'
}`}>
{status === 'active' ? t('active', language) : t('inactive', language)}
</span>
)
},
{
title: t('role', language),
dataIndex: 'role',
key: 'role',
width: 120
},
{
title: t('createdAt', language),
dataIndex: 'createdAt',
key: 'createdAt',
width: 160
},
{
title: t('actions', language),
key: 'actions',
width: 150,
render: (_: unknown, record: typeof users[0]) => (
<div className="flex gap-2">
<Button
type="link"
size="small"
onClick={() => {
const newData = { ...record, name: record.name + ' (已编辑)' };
updateUser(record.id, newData);
}}
>
{t('edit', language)}
</Button>
<Button
type="link"
size="small"
danger
onClick={() => deleteUser(record.id)}
>
{t('delete', language)}
</Button>
</div>
)
}
];
return (
<div style={style}>
<Table
columns={columns}
dataSource={users}
rowKey="id"
pagination={{ pageSize: 5 }}
scroll={{ x: 1000 }}
/>
</div>
);
case 'form':
return (
<div style={{ ...style, border: '1px solid #e2e8f0', borderRadius: 8, background: '#fafafa' }}>
<div className="text-lg font-semibold mb-4 p-4 border-b border-gray-200">
{props.title as string}
</div>
<Form layout="vertical" className="p-4">
<Form.Item label={t('userName', language)} required>
<Input placeholder={t('pleaseInput', language) + t('userName', language)} />
</Form.Item>
<Form.Item label={t('email', language)} required>
<Input placeholder={t('pleaseInput', language) + t('email', language)} />
</Form.Item>
<Form.Item label={t('phone', language)}>
<Input placeholder={t('pleaseInput', language) + t('phone', language)} />
</Form.Item>
<Form.Item label={t('department', language)}>
<Select placeholder={t('pleaseSelect', language)}>
<Select.Option value="tech">{t('techDept', language) || '技术部'}</Select.Option>
<Select.Option value="product">{t('productDept', language) || '产品部'}</Select.Option>
<Select.Option value="design">{t('designDept', language) || '设计部'}</Select.Option>
</Select>
</Form.Item>
<Form.Item>
<Button type="primary">{t('save', language)}</Button>
<Button className="ml-2">{t('cancel', language)}</Button>
</Form.Item>
</Form>
</div>
);
default:
return <div style={style}>Unknown component: {type}</div>;
}
};
if (isPreview) {
return renderComponent();
}
return (
<div
className={`dropped-component ${isSelected ? 'selected' : ''}`}
onClick={handleClick}
>
<div className="component-toolbar">
<button className="toolbar-btn delete" onClick={handleDelete} title={t('delete', language)}>
🗑
</button>
</div>
{renderComponent()}
</div>
);
};

View File

@ -0,0 +1,2 @@
export { CanvasArea } from './CanvasArea';
export { ComponentRenderer } from './ComponentRenderer';

View File

@ -0,0 +1,38 @@
import React from 'react';
import { useDraggable } from '@dnd-kit/core';
import { ComponentType } from '@/types';
import { useDesignerStore } from '@/store';
import { t } from '@/locales';
interface DraggableComponentItemProps {
type: ComponentType;
name: string;
nameEn: string;
icon: React.ReactNode;
}
export const DraggableComponentItem: React.FC<DraggableComponentItemProps> = ({
type,
name,
nameEn,
icon
}) => {
const { attributes, listeners, setNodeRef, isDragging } = useDraggable({
id: `draggable-${type}`,
data: { type, isNew: true }
});
const language = useDesignerStore((state) => state.language);
return (
<div
ref={setNodeRef}
{...listeners}
{...attributes}
className={`draggable-component ${isDragging ? 'opacity-50' : ''}`}
>
<span className="text-lg">{icon}</span>
<span className="text-sm font-medium">{language === 'zh' ? name : nameEn}</span>
</div>
);
};

View File

@ -0,0 +1,78 @@
import React from 'react';
import { DraggableComponentItem } from './DraggableComponentItem';
import { ComponentType } from '@/types';
import { t } from '@/locales';
import { useDesignerStore } from '@/store';
const componentList: {
category: 'form' | 'display' | 'action';
categoryLabel: { zh: string; en: string };
components: {
type: ComponentType;
name: string;
nameEn: string;
icon: string;
}[];
}[] = [
{
category: 'form',
categoryLabel: { zh: '表单组件', en: 'Form Components' },
components: [
{ type: 'input', name: '输入框', nameEn: 'Input', icon: '📝' },
{ type: 'textarea', name: '多行文本', nameEn: 'Textarea', icon: '📄' },
{ type: 'number', name: '数字输入', nameEn: 'Number', icon: '🔢' },
{ type: 'select', name: '下拉选择', nameEn: 'Select', icon: '📋' },
{ type: 'datePicker', name: '日期选择', nameEn: 'Date Picker', icon: '📅' },
{ type: 'checkbox', name: '复选框', nameEn: 'Checkbox', icon: '☑️' },
{ type: 'radio', name: '单选框', nameEn: 'Radio', icon: '🔘' }
]
},
{
category: 'display',
categoryLabel: { zh: '展示组件', en: 'Display Components' },
components: [
{ type: 'text', name: '文本', nameEn: 'Text', icon: '📃' },
{ type: 'table', name: '表格', nameEn: 'Table', icon: '📊' },
{ type: 'form', name: '表单容器', nameEn: 'Form', icon: '📋' }
]
},
{
category: 'action',
categoryLabel: { zh: '操作组件', en: 'Action Components' },
components: [
{ type: 'button', name: '按钮', nameEn: 'Button', icon: '🔘' },
{ type: 'search', name: '搜索框', nameEn: 'Search', icon: '🔍' }
]
}
];
export const ComponentPanel: React.FC = () => {
const language = useDesignerStore((state) => state.language);
return (
<div className="component-panel">
<div className="panel-header">
<span>🧩</span>
<span className="ml-2">{t('componentLibrary', language)}</span>
</div>
<div className="flex-1 overflow-y-auto">
{componentList.map((category) => (
<div key={category.category}>
<div className="category-title">
{language === 'zh' ? category.categoryLabel.zh : category.categoryLabel.en}
</div>
{category.components.map((comp) => (
<DraggableComponentItem
key={comp.type}
type={comp.type}
name={comp.name}
nameEn={comp.nameEn}
icon={<span>{comp.icon}</span>}
/>
))}
</div>
))}
</div>
</div>
);
};

View File

@ -0,0 +1,283 @@
import React from 'react';
import { Input, InputNumber, Select, Switch, ColorPicker, Button, Divider, Collapse } from 'antd';
import { useDesignerStore } from '@/store';
import { t } from '@/locales';
import { DesignerComponent, ComponentType } from '@/types';
interface PropertyPanelProps {
selectedComponent: DesignerComponent | null;
}
const componentTypeOptions: { type: ComponentType; labelKey: string }[] = [
{ type: 'input', labelKey: 'input' },
{ type: 'textarea', labelKey: 'textarea' },
{ type: 'number', labelKey: 'number' },
{ type: 'select', labelKey: 'select' },
{ type: 'datePicker', labelKey: 'datePicker' },
{ type: 'checkbox', labelKey: 'checkbox' },
{ type: 'radio', labelKey: 'radio' },
{ type: 'button', labelKey: 'button' },
{ type: 'search', labelKey: 'search' },
{ type: 'text', labelKey: 'text' },
{ type: 'table', labelKey: 'table' },
{ type: 'form', labelKey: 'form' }
];
export const PropertyPanel: React.FC<PropertyPanelProps> = ({ selectedComponent }) => {
const language = useDesignerStore((state) => state.language);
const updateComponent = useDesignerStore((state) => state.updateComponent);
if (!selectedComponent) {
return (
<div className="property-panel">
<div className="panel-header">
<span></span>
<span className="ml-2">{t('propertyPanel', language)}</span>
</div>
<div className="flex flex-col items-center justify-center h-64 text-gray-400">
<div className="text-4xl mb-4">📋</div>
<div className="text-sm">{t('noComponentSelected', language)}</div>
</div>
</div>
);
}
const { type, props, style } = selectedComponent;
const updateProps = (key: string, value: unknown) => {
updateComponent(selectedComponent.id, {
props: { ...props, [key]: value }
});
};
const updateStyle = (key: string, value: unknown) => {
updateComponent(selectedComponent.id, {
style: { ...style, [key]: value }
});
};
const renderBasicProps = () => {
switch (type) {
case 'input':
case 'textarea':
case 'number':
case 'select':
case 'datePicker':
case 'checkbox':
case 'radio':
return (
<>
<div className="property-field">
<label className="property-label">{t('label', language)}</label>
<Input
value={props.label as string}
onChange={(e) => updateProps('label', e.target.value)}
/>
</div>
<div className="property-field">
<label className="property-label">{t('placeholder', language)}</label>
<Input
value={props.placeholder as string}
onChange={(e) => updateProps('placeholder', e.target.value)}
/>
</div>
<div className="property-field">
<label className="property-label">{t('required', language)}</label>
<Switch
checked={props.required as boolean}
onChange={(checked) => updateProps('required', checked)}
/>
</div>
</>
);
case 'button':
return (
<>
<div className="property-field">
<label className="property-label">{t('buttonText', language)}</label>
<Input
value={props.text as string}
onChange={(e) => updateProps('text', e.target.value)}
/>
</div>
<div className="property-field">
<label className="property-label">{t('buttonType', language)}</label>
<Select
value={props.buttonType as string}
onChange={(value) => updateProps('buttonType', value)}
options={[
{ label: t('primary', language), value: 'primary' },
{ label: t('defaultBtn', language), value: 'default' },
{ label: t('dashed', language), value: 'dashed' },
{ label: t('textBtn', language), value: 'text' },
{ label: t('link', language), value: 'link' }
]}
/>
</div>
</>
);
case 'search':
return (
<div className="property-field">
<label className="property-label">{t('placeholder', language)}</label>
<Input
value={props.placeholder as string}
onChange={(e) => updateProps('placeholder', e.target.value)}
/>
</div>
);
case 'text':
return (
<>
<div className="property-field">
<label className="property-label">{t('content', language) || '内容'}</label>
<Input.TextArea
value={props.content as string}
onChange={(e) => updateProps('content', e.target.value)}
rows={3}
/>
</div>
<div className="property-field">
<label className="property-label">{t('fontSize', language)}</label>
<InputNumber
value={props.fontSize as number}
onChange={(value) => updateProps('fontSize', value)}
min={12}
max={72}
/>
</div>
</>
);
case 'table':
return (
<div className="property-field">
<label className="property-label">{t('dataSource', language)}</label>
<Select
value={props.dataSource as string}
onChange={(value) => updateProps('dataSource', value)}
options={[
{ label: t('userManagement', language), value: 'users' }
]}
/>
</div>
);
case 'form':
return (
<div className="property-field">
<label className="property-label">{t('title', language) || '标题'}</label>
<Input
value={props.title as string}
onChange={(e) => updateProps('title', e.target.value)}
/>
</div>
);
default:
return null;
}
};
const renderStyleProps = () => (
<>
<div className="property-field">
<label className="property-label">{t('width', language)}</label>
<Input
value={style.width as string}
onChange={(e) => updateStyle('width', e.target.value)}
placeholder="e.g., 100%, 200px"
/>
</div>
<div className="property-field">
<label className="property-label">{t('height', language)}</label>
<Input
value={style.height as string}
onChange={(e) => updateStyle('height', e.target.value)}
placeholder="e.g., auto, 100px"
/>
</div>
<div className="property-field">
<label className="property-label">{t('fontSize', language)}</label>
<InputNumber
value={style.fontSize as number}
onChange={(value) => updateStyle('fontSize', value)}
min={12}
max={72}
/>
</div>
<div className="property-field">
<label className="property-label">{t('color', language)}</label>
<Input
type="color"
value={style.color as string}
onChange={(e) => updateStyle('color', e.target.value)}
style={{ height: 40 }}
/>
</div>
<div className="property-field">
<label className="property-label">{t('backgroundColor', language)}</label>
<Input
type="color"
value={style.backgroundColor as string}
onChange={(e) => updateStyle('backgroundColor', e.target.value)}
style={{ height: 40 }}
/>
</div>
<div className="property-field">
<label className="property-label">{t('borderRadius', language)}</label>
<InputNumber
value={parseInt(style.borderRadius as string) || 0}
onChange={(value) => updateStyle('borderRadius', `${value}px`)}
min={0}
max={50}
/>
</div>
</>
);
const componentLabel = componentTypeOptions.find((c) => c.type === type);
return (
<div className="property-panel">
<div className="panel-header">
<span></span>
<span className="ml-2">{t('propertyPanel', language)}</span>
</div>
<div className="panel-section">
<div className="panel-section-title">{language === 'zh' ? '组件信息' : 'Component Info'}</div>
<div className="property-field">
<label className="property-label">{language === 'zh' ? '组件类型' : 'Type'}</label>
<div className="text-sm font-medium text-gray-700">
{componentLabel ? t(componentLabel.labelKey, language) : type}
</div>
</div>
<div className="property-field">
<label className="property-label">ID</label>
<div className="text-xs text-gray-400 font-mono">{selectedComponent.id}</div>
</div>
</div>
<Collapse
defaultActiveKey={['basic', 'style']}
ghost
items={[
{
key: 'basic',
label: <span className="font-medium">{language === 'zh' ? '基础属性' : 'Basic Properties'}</span>,
children: renderBasicProps()
},
{
key: 'style',
label: <span className="font-medium">{language === 'zh' ? '样式设置' : 'Style Settings'}</span>,
children: renderStyleProps()
}
]}
/>
</div>
);
};

361
src/index.css Normal file
View File

@ -0,0 +1,361 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
:root {
--primary-color: #2563eb;
--bg-color: #f8fafc;
--border-color: #e2e8f0;
--text-color: #1e293b;
--text-secondary: #64748b;
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
background-color: var(--bg-color);
color: var(--text-color);
line-height: 1.6;
}
.designer-layout {
display: flex;
height: 100vh;
overflow: hidden;
}
.component-panel {
width: 280px;
background: #fff;
border-right: 1px solid var(--border-color);
display: flex;
flex-direction: column;
}
.canvas-area {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
}
.property-panel {
width: 320px;
background: #fff;
border-left: 1px solid var(--border-color);
overflow-y: auto;
}
.draggable-component {
padding: 12px;
margin: 8px;
background: #fff;
border: 1px solid var(--border-color);
border-radius: 8px;
cursor: grab;
transition: all 0.2s ease;
display: flex;
align-items: center;
gap: 10px;
}
.draggable-component:hover {
border-color: var(--primary-color);
box-shadow: 0 2px 8px rgba(37, 99, 235, 0.15);
transform: translateY(-1px);
}
.draggable-component:active {
cursor: grabbing;
}
.canvas-content {
flex: 1;
background: #f1f5f9;
padding: 24px;
overflow-y: auto;
min-height: 100%;
}
.canvas-wrapper {
background: #fff;
border-radius: 12px;
min-height: calc(100vh - 120px);
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
padding: 20px;
border: 2px dashed transparent;
transition: border-color 0.2s;
}
.canvas-wrapper.drag-over {
border-color: var(--primary-color);
background: rgba(37, 99, 235, 0.02);
}
.dropped-component {
position: relative;
border: 2px solid transparent;
border-radius: 6px;
padding: 12px;
margin-bottom: 12px;
transition: all 0.2s ease;
background: #fff;
}
.dropped-component:hover {
border-color: #3b82f6;
}
.dropped-component.selected {
border-color: var(--primary-color);
box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.2);
}
.component-toolbar {
position: absolute;
top: -12px;
right: 8px;
display: none;
gap: 4px;
background: #fff;
border-radius: 6px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
padding: 4px;
}
.dropped-component:hover .component-toolbar,
.dropped-component.selected .component-toolbar {
display: flex;
}
.toolbar-btn {
width: 24px;
height: 24px;
border: none;
background: transparent;
cursor: pointer;
border-radius: 4px;
display: flex;
align-items: center;
justify-content: center;
color: #64748b;
transition: all 0.2s;
}
.toolbar-btn:hover {
background: #f1f5f9;
color: var(--primary-color);
}
.toolbar-btn.delete:hover {
background: #fef2f2;
color: #ef4444;
}
.panel-header {
padding: 16px 20px;
border-bottom: 1px solid var(--border-color);
font-weight: 600;
font-size: 14px;
color: var(--text-color);
background: linear-gradient(to bottom, #fff, #fafafa);
}
.panel-section {
padding: 16px;
border-bottom: 1px solid var(--border-color);
}
.panel-section-title {
font-size: 12px;
font-weight: 600;
color: var(--text-secondary);
text-transform: uppercase;
letter-spacing: 0.5px;
margin-bottom: 12px;
}
.property-field {
margin-bottom: 16px;
}
.property-label {
display: block;
font-size: 13px;
color: var(--text-secondary);
margin-bottom: 6px;
}
.empty-canvas {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 400px;
color: var(--text-secondary);
text-align: center;
}
.empty-canvas-icon {
font-size: 48px;
margin-bottom: 16px;
opacity: 0.5;
}
.preview-mode {
padding: 24px;
}
.preview-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 24px;
padding-bottom: 16px;
border-bottom: 1px solid var(--border-color);
}
.page-title {
font-size: 24px;
font-weight: 700;
color: var(--text-color);
}
.search-bar {
display: flex;
gap: 12px;
margin-bottom: 20px;
flex-wrap: wrap;
}
.user-table-wrapper {
background: #fff;
border-radius: 12px;
overflow: hidden;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
}
.category-title {
font-size: 11px;
font-weight: 600;
color: var(--text-secondary);
text-transform: uppercase;
letter-spacing: 0.5px;
padding: 12px 16px 8px;
background: #fafafa;
border-top: 1px solid var(--border-color);
}
.category-title:first-child {
border-top: none;
}
.header-bar {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 20px;
background: #fff;
border-bottom: 1px solid var(--border-color);
}
.header-title {
font-size: 16px;
font-weight: 600;
display: flex;
align-items: center;
gap: 8px;
}
.header-actions {
display: flex;
align-items: center;
gap: 12px;
}
.mode-tabs {
display: flex;
background: #f1f5f9;
border-radius: 8px;
padding: 4px;
}
.mode-tab {
padding: 8px 16px;
border: none;
background: transparent;
cursor: pointer;
border-radius: 6px;
font-size: 13px;
font-weight: 500;
color: var(--text-secondary);
transition: all 0.2s;
}
.mode-tab.active {
background: #fff;
color: var(--primary-color);
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
}
.drop-indicator {
height: 3px;
background: var(--primary-color);
border-radius: 2px;
margin: 4px 0;
}
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.fade-in {
animation: fadeIn 0.3s ease;
}
@keyframes pulse {
0%, 100% {
opacity: 1;
}
50% {
opacity: 0.5;
}
}
.pulse {
animation: pulse 2s ease-in-out infinite;
}
.ant-input, .ant-select-selector, .ant-picker {
border-radius: 6px !important;
}
.ant-btn {
border-radius: 6px !important;
font-weight: 500 !important;
}
.ant-table-thead > tr > th {
background: #f8fafc !important;
font-weight: 600 !important;
}
.ant-modal-content {
border-radius: 12px !important;
}
.ant-modal-header {
border-radius: 12px 12px 0 0 !important;
}

320
src/locales/index.ts Normal file
View File

@ -0,0 +1,320 @@
import { Locale } from '@/types';
export const locale: Locale = {
designer: {
zh: '设计器',
en: 'Designer'
},
preview: {
zh: '预览',
en: 'Preview'
},
componentLibrary: {
zh: '组件库',
en: 'Components'
},
propertyPanel: {
zh: '属性配置',
en: 'Properties'
},
formComponents: {
zh: '表单组件',
en: 'Form'
},
displayComponents: {
zh: '展示组件',
en: 'Display'
},
actionComponents: {
zh: '操作组件',
en: 'Action'
},
input: {
zh: '输入框',
en: 'Input'
},
select: {
zh: '下拉选择',
en: 'Select'
},
datePicker: {
zh: '日期选择',
en: 'Date Picker'
},
button: {
zh: '按钮',
en: 'Button'
},
table: {
zh: '表格',
en: 'Table'
},
form: {
zh: '表单',
en: 'Form'
},
searchComponent: {
zh: '搜索框',
en: 'Search'
},
textComponent: {
zh: '文本',
en: 'Text'
},
number: {
zh: '数字输入',
en: 'Number'
},
checkbox: {
zh: '复选框',
en: 'Checkbox'
},
radio: {
zh: '单选框',
en: 'Radio'
},
textarea: {
zh: '多行文本',
en: 'Textarea'
},
label: {
zh: '标签',
en: 'Label'
},
placeholder: {
zh: '占位符',
en: 'Placeholder'
},
required: {
zh: '必填',
en: 'Required'
},
width: {
zh: '宽度',
en: 'Width'
},
height: {
zh: '高度',
en: 'Height'
},
fontSize: {
zh: '字体大小',
en: 'Font Size'
},
color: {
zh: '颜色',
en: 'Color'
},
backgroundColor: {
zh: '背景色',
en: 'Background'
},
border: {
zh: '边框',
en: 'Border'
},
borderRadius: {
zh: '圆角',
en: 'Border Radius'
},
buttonText: {
zh: '按钮文字',
en: 'Button Text'
},
buttonType: {
zh: '按钮类型',
en: 'Button Type'
},
primary: {
zh: '主要',
en: 'Primary'
},
defaultBtn: {
zh: '默认',
en: 'Default'
},
dashed: {
zh: '虚线',
en: 'Dashed'
},
textBtn: {
zh: '文本',
en: 'Text'
},
link: {
zh: '链接',
en: 'Link'
},
columns: {
zh: '列配置',
en: 'Columns'
},
dataSource: {
zh: '数据源',
en: 'Data Source'
},
addUser: {
zh: '新增用户',
en: 'Add User'
},
editUser: {
zh: '编辑用户',
en: 'Edit User'
},
deleteUser: {
zh: '删除用户',
en: 'Delete User'
},
searchUser: {
zh: '搜索用户',
en: 'Search User'
},
userName: {
zh: '用户名',
en: 'Username'
},
email: {
zh: '邮箱',
en: 'Email'
},
phone: {
zh: '电话',
en: 'Phone'
},
department: {
zh: '部门',
en: 'Department'
},
status: {
zh: '状态',
en: 'Status'
},
role: {
zh: '角色',
en: 'Role'
},
active: {
zh: '启用',
en: 'Active'
},
inactive: {
zh: '停用',
en: 'Inactive'
},
createdAt: {
zh: '创建时间',
en: 'Created At'
},
actions: {
zh: '操作',
en: 'Actions'
},
edit: {
zh: '编辑',
en: 'Edit'
},
delete: {
zh: '删除',
en: 'Delete'
},
confirm: {
zh: '确认',
en: 'Confirm'
},
cancel: {
zh: '取消',
en: 'Cancel'
},
save: {
zh: '保存',
en: 'Save'
},
reset: {
zh: '重置',
en: 'Reset'
},
search: {
zh: '搜索',
en: 'Search'
},
add: {
zh: '新增',
en: 'Add'
},
export: {
zh: '导出',
en: 'Export'
},
import: {
zh: '导入',
en: 'Import'
},
allDepartments: {
zh: '全部部门',
en: 'All Departments'
},
allStatus: {
zh: '全部状态',
en: 'All Status'
},
pleaseInput: {
zh: '请输入',
en: 'Please input'
},
pleaseSelect: {
zh: '请选择',
en: 'Please select'
},
confirmDelete: {
zh: '确定要删除这条记录吗?',
en: 'Are you sure to delete this record?'
},
deleteSuccess: {
zh: '删除成功',
en: 'Deleted successfully'
},
saveSuccess: {
zh: '保存成功',
en: 'Saved successfully'
},
dragTip: {
zh: '从左侧拖拽组件到此处',
en: 'Drag components from the left panel'
},
noComponentSelected: {
zh: '请选择一个组件进行配置',
en: 'Select a component to configure'
},
userManagement: {
zh: '用户信息管理',
en: 'User Management'
},
lowcodePlatform: {
zh: '低代码平台',
en: 'Low-code Platform'
},
switchLanguage: {
zh: '切换语言',
en: 'Switch Language'
},
zh: {
zh: '中文',
en: 'Chinese'
},
en: {
zh: '英文',
en: 'English'
},
content: {
zh: '内容',
en: 'Content'
},
title: {
zh: '标题',
en: 'Title'
}
};
export const t = (key: string, lang: 'zh' | 'en'): string => {
return locale[key]?.[lang] || key;
};

10
src/main.tsx Normal file
View File

@ -0,0 +1,10 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App';
import './index.css';
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<App />
</React.StrictMode>
);

View File

@ -0,0 +1,198 @@
import React from 'react';
import {
DndContext,
DragEndEvent,
DragOverlay,
DragStartEvent,
closestCenter,
PointerSensor,
useSensor,
useSensors
} from '@dnd-kit/core';
import { arrayMove, SortableContext, verticalListSortingStrategy, useSortable } from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import { ComponentPanel } from '@/components/ComponentPanel';
import { CanvasArea, ComponentRenderer } from '@/components/Canvas';
import { PropertyPanel } from '@/components/PropertyPanel';
import { useDesignerStore } from '@/store';
import { t } from '@/locales';
import { ComponentType, DesignerComponent } from '@/types';
import { Button, Select, Space, Tooltip } from 'antd';
import { GlobalOutlined, EyeOutlined, EditOutlined, ClearOutlined } from '@ant-design/icons';
interface SortableItemProps {
component: DesignerComponent;
}
const SortableItem: React.FC<SortableItemProps> = ({ component }) => {
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging
} = useSortable({ id: component.id });
const style = {
transform: CSS.Transform.toString(transform),
transition,
opacity: isDragging ? 0.5 : 1
};
return (
<div ref={setNodeRef} style={style} {...attributes} {...listeners}>
<ComponentRenderer component={component} />
</div>
);
};
export const DesignerPage: React.FC = () => {
const components = useDesignerStore((state) => state.components);
const selectedComponentId = useDesignerStore((state) => state.selectedComponentId);
const mode = useDesignerStore((state) => state.mode);
const language = useDesignerStore((state) => state.language);
const addComponent = useDesignerStore((state) => state.addComponent);
const moveComponent = useDesignerStore((state) => state.moveComponent);
const setMode = useDesignerStore((state) => state.setMode);
const setLanguage = useDesignerStore((state) => state.setLanguage);
const deleteComponent = useDesignerStore((state) => state.deleteComponent);
const [activeId, setActiveId] = React.useState<string | null>(null);
const sensors = useSensors(
useSensor(PointerSensor, {
activationConstraint: {
distance: 5
}
})
);
const selectedComponent = components.find((c) => c.id === selectedComponentId) || null;
const handleDragStart = (event: DragStartEvent) => {
setActiveId(event.active.id as string);
};
const handleDragEnd = (event: DragEndEvent) => {
const { active, over } = event;
setActiveId(null);
if (!over) return;
if (active.data.current?.isNew) {
const type = active.data.current.type as ComponentType;
if (over.id === 'canvas-droppable') {
addComponent(type);
} else if (typeof over.id === 'string' && over.id.startsWith('dropped-')) {
const overIndex = components.findIndex((c) => c.id === over.id.replace('dropped-', ''));
if (overIndex !== -1) {
addComponent(type, undefined, overIndex);
}
}
} else {
const oldIndex = components.findIndex((c) => c.id === active.id);
const newIndex = components.findIndex((c) => c.id === over.id);
if (oldIndex !== -1 && newIndex !== -1 && oldIndex !== newIndex) {
moveComponent(active.id as string, newIndex);
}
}
};
const handleClearCanvas = () => {
components.forEach((c) => deleteComponent(c.id));
};
return (
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
>
<div className="designer-layout">
<ComponentPanel />
<div className="canvas-area">
<div className="header-bar">
<div className="header-title">
<span className="text-xl">🎨</span>
<span>{t('lowcodePlatform', language)}</span>
</div>
<div className="header-actions">
<Select
value={language}
onChange={setLanguage}
style={{ width: 100 }}
options={[
{ label: '中文', value: 'zh' },
{ label: 'EN', value: 'en' }
]}
/>
<div className="mode-tabs">
<button
className={`mode-tab ${mode === 'design' ? 'active' : ''}`}
onClick={() => setMode('design')}
>
<EditOutlined className="mr-1" />
{t('designer', language)}
</button>
<button
className={`mode-tab ${mode === 'preview' ? 'active' : ''}`}
onClick={() => setMode('preview')}
>
<EyeOutlined className="mr-1" />
{t('preview', language)}
</button>
</div>
{mode === 'design' && components.length > 0 && (
<Tooltip title={language === 'zh' ? '清空画布' : 'Clear Canvas'}>
<Button danger icon={<ClearOutlined />} onClick={handleClearCanvas}>
{language === 'zh' ? '清空' : 'Clear'}
</Button>
</Tooltip>
)}
</div>
</div>
{mode === 'design' ? (
<CanvasArea components={components} />
) : (
<div className="canvas-content">
<div className="canvas-wrapper">
{components.length === 0 ? (
<div className="empty-canvas">
<div className="empty-canvas-icon">📝</div>
<div className="text-lg font-medium mb-2">
{language === 'zh' ? '暂无设计内容' : 'No design content'}
</div>
<div className="text-sm text-gray-400">
{language === 'zh'
? '请先在设计模式下添加组件'
: 'Please add components in design mode first'}
</div>
</div>
) : (
components.map((component) => (
<ComponentRenderer key={component.id} component={component} isPreview />
))
)}
</div>
</div>
)}
</div>
{mode === 'design' && <PropertyPanel selectedComponent={selectedComponent} />}
</div>
<DragOverlay>
{activeId && activeId.startsWith('draggable-') && (
<div className="draggable-component" style={{ opacity: 0.8 }}>
{activeId.replace('draggable-', '')}
</div>
)}
</DragOverlay>
</DndContext>
);
};

272
src/pages/Preview/index.tsx Normal file
View File

@ -0,0 +1,272 @@
import React, { useState } from 'react';
import {
Table, Button, Input, Select, Space, Modal, Form,
message, Tag, Card, Typography
} from 'antd';
import { PlusOutlined, SearchOutlined, ReloadOutlined } from '@ant-design/icons';
import { useDesignerStore } from '@/store';
import { t } from '@/locales';
import { User } from '@/types';
const { Title } = Typography;
export const PreviewPage: React.FC = () => {
const language = useDesignerStore((state) => state.language);
const users = useDesignerStore((state) => state.users);
const addUser = useDesignerStore((state) => state.addUser);
const updateUser = useDesignerStore((state) => state.updateUser);
const deleteUser = useDesignerStore((state) => state.deleteUser);
const resetUsers = useDesignerStore((state) => state.resetUsers);
const [searchText, setSearchText] = useState('');
const [departmentFilter, setDepartmentFilter] = useState<string>('');
const [statusFilter, setStatusFilter] = useState<string>('');
const [isModalOpen, setIsModalOpen] = useState(false);
const [editingUser, setEditingUser] = useState<User | null>(null);
const [form] = Form.useForm();
const departments = [...new Set(users.map((u) => u.department))];
const filteredUsers = users.filter((user) => {
const matchSearch = !searchText ||
user.name.toLowerCase().includes(searchText.toLowerCase()) ||
user.email.toLowerCase().includes(searchText.toLowerCase());
const matchDept = !departmentFilter || user.department === departmentFilter;
const matchStatus = !statusFilter || user.status === statusFilter;
return matchSearch && matchDept && matchStatus;
});
const handleAdd = () => {
setEditingUser(null);
form.resetFields();
setIsModalOpen(true);
};
const handleEdit = (user: User) => {
setEditingUser(user);
form.setFieldsValue(user);
setIsModalOpen(true);
};
const handleDelete = (id: string) => {
Modal.confirm({
title: t('confirmDelete', language),
content: language === 'zh' ? '此操作不可恢复' : 'This action cannot be undone',
okText: t('confirm', language),
cancelText: t('cancel', language),
okType: 'danger',
onOk: () => {
deleteUser(id);
message.success(t('deleteSuccess', language));
}
});
};
const handleModalOk = () => {
form.validateFields().then((values) => {
if (editingUser) {
updateUser(editingUser.id, values);
message.success(t('saveSuccess', language));
} else {
addUser(values);
message.success(t('saveSuccess', language));
}
setIsModalOpen(false);
});
};
const handleReset = () => {
setSearchText('');
setDepartmentFilter('');
setStatusFilter('');
resetUsers();
message.success(language === 'zh' ? '数据已重置' : 'Data has been reset');
};
const columns = [
{
title: t('userName', language),
dataIndex: 'name',
key: 'name',
width: 120,
fixed: 'left' as const
},
{
title: t('email', language),
dataIndex: 'email',
key: 'email',
width: 200
},
{
title: t('phone', language),
dataIndex: 'phone',
key: 'phone',
width: 130
},
{
title: t('department', language),
dataIndex: 'department',
key: 'department',
width: 100
},
{
title: t('status', language),
dataIndex: 'status',
key: 'status',
width: 80,
render: (status: string) => (
<Tag color={status === 'active' ? 'success' : 'error'}>
{status === 'active' ? t('active', language) : t('inactive', language)}
</Tag>
)
},
{
title: t('role', language),
dataIndex: 'role',
key: 'role',
width: 120
},
{
title: t('createdAt', language),
dataIndex: 'createdAt',
key: 'createdAt',
width: 160
},
{
title: t('actions', language),
key: 'actions',
width: 150,
fixed: 'right' as const,
render: (_: unknown, record: User) => (
<Space>
<Button type="link" size="small" onClick={() => handleEdit(record)}>
{t('edit', language)}
</Button>
<Button type="link" size="small" danger onClick={() => handleDelete(record.id)}>
{t('delete', language)}
</Button>
</Space>
)
}
];
return (
<div className="preview-mode">
<Card className="mb-4">
<div className="preview-header">
<Title level={3} style={{ margin: 0 }}>
{t('userManagement', language)}
</Title>
<Button onClick={handleReset} icon={<ReloadOutlined />}>
{language === 'zh' ? '重置数据' : 'Reset Data'}
</Button>
</div>
</Card>
<Card className="mb-4">
<div className="search-bar">
<Input
placeholder={t('searchUser', language)}
prefix={<SearchOutlined />}
value={searchText}
onChange={(e) => setSearchText(e.target.value)}
style={{ width: 250 }}
allowClear
/>
<Select
placeholder={t('allDepartments', language)}
value={departmentFilter || undefined}
onChange={setDepartmentFilter}
style={{ width: 150 }}
allowClear
>
{departments.map((dept) => (
<Select.Option key={dept} value={dept}>
{dept}
</Select.Option>
))}
</Select>
<Select
placeholder={t('allStatus', language)}
value={statusFilter || undefined}
onChange={setStatusFilter}
style={{ width: 120 }}
allowClear
>
<Select.Option value="active">{t('active', language)}</Select.Option>
<Select.Option value="inactive">{t('inactive', language)}</Select.Option>
</Select>
<Button type="primary" icon={<PlusOutlined />} onClick={handleAdd}>
{t('addUser', language)}
</Button>
</div>
</Card>
<Card>
<Table
columns={columns}
dataSource={filteredUsers}
rowKey="id"
pagination={{
pageSize: 10,
showSizeChanger: true,
showTotal: (total) =>
language === 'zh' ? `${total} 条记录` : `Total ${total} records`
}}
scroll={{ x: 1200 }}
bordered
/>
</Card>
<Modal
title={editingUser ? t('editUser', language) : t('addUser', language)}
open={isModalOpen}
onOk={handleModalOk}
onCancel={() => setIsModalOpen(false)}
okText={t('confirm', language)}
cancelText={t('cancel', language)}
width={500}
>
<Form form={form} layout="vertical">
<Form.Item
name="name"
label={t('userName', language)}
rules={[{ required: true, message: t('pleaseInput', language) + t('userName', language) }]}
>
<Input />
</Form.Item>
<Form.Item
name="email"
label={t('email', language)}
rules={[
{ required: true, message: t('pleaseInput', language) + t('email', language) },
{ type: 'email', message: language === 'zh' ? '邮箱格式不正确' : 'Invalid email format' }
]}
>
<Input />
</Form.Item>
<Form.Item name="phone" label={t('phone', language)}>
<Input />
</Form.Item>
<Form.Item name="department" label={t('department', language)}>
<Select>
<Select.Option value="技术部"></Select.Option>
<Select.Option value="产品部"></Select.Option>
<Select.Option value="设计部"></Select.Option>
<Select.Option value="运营部"></Select.Option>
</Select>
</Form.Item>
<Form.Item name="status" label={t('status', language)}>
<Select>
<Select.Option value="active">{t('active', language)}</Select.Option>
<Select.Option value="inactive">{t('inactive', language)}</Select.Option>
</Select>
</Form.Item>
<Form.Item name="role" label={t('role', language)}>
<Input />
</Form.Item>
</Form>
</Modal>
</div>
);
};

233
src/store/index.ts Normal file
View File

@ -0,0 +1,233 @@
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
import { DesignerComponent, ComponentType, Language, User } from '@/types';
import { v4 as uuidv4 } from 'uuid';
interface DesignerState {
components: DesignerComponent[];
selectedComponentId: string | null;
mode: 'design' | 'preview';
language: Language;
users: User[];
addComponent: (type: ComponentType, parentId?: string, index?: number) => string;
updateComponent: (id: string, updates: Partial<DesignerComponent>) => void;
deleteComponent: (id: string) => void;
selectComponent: (id: string | null) => void;
moveComponent: (id: string, newIndex: number) => void;
setMode: (mode: 'design' | 'preview') => void;
setLanguage: (lang: Language) => void;
addUser: (user: Omit<User, 'id' | 'createdAt' | 'updatedAt'>) => void;
updateUser: (id: string, updates: Partial<User>) => void;
deleteUser: (id: string) => void;
resetUsers: () => void;
}
const initialUsers: User[] = [
{
id: '1',
name: '张三',
email: 'zhangsan@example.com',
phone: '13800138001',
department: '技术部',
status: 'active',
role: '开发工程师',
createdAt: '2024-01-15 10:30:00',
updatedAt: '2024-01-15 10:30:00'
},
{
id: '2',
name: '李四',
email: 'lisi@example.com',
phone: '13800138002',
department: '产品部',
status: 'active',
role: '产品经理',
createdAt: '2024-01-16 14:20:00',
updatedAt: '2024-01-16 14:20:00'
},
{
id: '3',
name: '王五',
email: 'wangwu@example.com',
phone: '13800138003',
department: '设计部',
status: 'inactive',
role: 'UI设计师',
createdAt: '2024-01-17 09:15:00',
updatedAt: '2024-01-20 16:45:00'
},
{
id: '4',
name: '赵六',
email: 'zhaoliu@example.com',
phone: '13800138004',
department: '技术部',
status: 'active',
role: '测试工程师',
createdAt: '2024-01-18 11:00:00',
updatedAt: '2024-01-18 11:00:00'
},
{
id: '5',
name: '孙七',
email: 'sunqi@example.com',
phone: '13800138005',
department: '运营部',
status: 'active',
role: '运营专员',
createdAt: '2024-01-19 08:30:00',
updatedAt: '2024-01-19 08:30:00'
}
];
const getDefaultProps = (type: ComponentType): Record<string, unknown> => {
const defaults: Record<ComponentType, Record<string, unknown>> = {
input: { label: '输入框', placeholder: '请输入内容', required: false },
select: { label: '下拉选择', placeholder: '请选择', options: [], required: false },
datePicker: { label: '日期选择', placeholder: '请选择日期', required: false },
button: { text: '按钮', buttonType: 'primary' },
table: { columns: [], dataSource: 'users' },
form: { title: '表单标题' },
search: { placeholder: '请输入搜索关键词' },
text: { content: '文本内容', fontSize: 14 },
number: { label: '数字输入', placeholder: '请输入数字', min: 0, max: 100 },
checkbox: { label: '复选框', checked: false },
radio: { label: '单选框', options: [] },
textarea: { label: '多行文本', placeholder: '请输入内容', rows: 4 }
};
return defaults[type] || {};
};
const getDefaultStyle = (type: ComponentType): React.CSSProperties => {
const defaults: Record<ComponentType, React.CSSProperties> = {
input: { width: '100%', marginBottom: 16 },
select: { width: '100%', marginBottom: 16 },
datePicker: { width: '100%', marginBottom: 16 },
button: { marginBottom: 16 },
table: { width: '100%' },
form: { width: '100%', padding: 20 },
search: { width: 250, marginBottom: 16 },
text: { marginBottom: 16 },
number: { width: '100%', marginBottom: 16 },
checkbox: { marginBottom: 16 },
radio: { marginBottom: 16 },
textarea: { width: '100%', marginBottom: 16 }
};
return defaults[type] || {};
};
export const useDesignerStore = create<DesignerState>()(
persist(
(set, get) => ({
components: [],
selectedComponentId: null,
mode: 'design',
language: 'zh',
users: initialUsers,
addComponent: (type, parentId, index) => {
const id = uuidv4();
const newComponent: DesignerComponent = {
id,
type,
props: getDefaultProps(type),
style: getDefaultStyle(type),
parentId
};
set((state) => {
if (typeof index === 'number') {
const newComponents = [...state.components];
newComponents.splice(index, 0, newComponent);
return { components: newComponents, selectedComponentId: id };
}
return {
components: [...state.components, newComponent],
selectedComponentId: id
};
});
return id;
},
updateComponent: (id, updates) => {
set((state) => ({
components: state.components.map((comp) =>
comp.id === id ? { ...comp, ...updates } : comp
)
}));
},
deleteComponent: (id) => {
set((state) => ({
components: state.components.filter((comp) => comp.id !== id),
selectedComponentId: state.selectedComponentId === id ? null : state.selectedComponentId
}));
},
selectComponent: (id) => {
set({ selectedComponentId: id });
},
moveComponent: (id, newIndex) => {
set((state) => {
const components = [...state.components];
const currentIndex = components.findIndex((c) => c.id === id);
if (currentIndex === -1) return state;
const [component] = components.splice(currentIndex, 1);
components.splice(newIndex, 0, component);
return { components };
});
},
setMode: (mode) => {
set({ mode, selectedComponentId: null });
},
setLanguage: (lang) => {
set({ language: lang });
},
addUser: (userData) => {
const now = new Date().toISOString().replace('T', ' ').slice(0, 19);
const newUser: User = {
...userData,
id: uuidv4(),
createdAt: now,
updatedAt: now
};
set((state) => ({ users: [...state.users, newUser] }));
},
updateUser: (id, updates) => {
const now = new Date().toISOString().replace('T', ' ').slice(0, 19);
set((state) => ({
users: state.users.map((user) =>
user.id === id ? { ...user, ...updates, updatedAt: now } : user
)
}));
},
deleteUser: (id) => {
set((state) => ({
users: state.users.filter((user) => user.id !== id)
}));
},
resetUsers: () => {
set({ users: initialUsers });
}
}),
{
name: 'lowcode-designer-storage',
partialize: (state) => ({
components: state.components,
language: state.language
})
}
)
);

68
src/types/index.ts Normal file
View File

@ -0,0 +1,68 @@
export type ComponentType =
| 'input'
| 'select'
| 'datePicker'
| 'button'
| 'table'
| 'form'
| 'search'
| 'text'
| 'number'
| 'checkbox'
| 'radio'
| 'textarea';
export interface ComponentConfig {
type: ComponentType;
name: string;
nameEn: string;
icon: string;
category: 'form' | 'display' | 'action';
defaultProps: Record<string, unknown>;
defaultStyle: React.CSSProperties;
}
export interface DesignerComponent {
id: string;
type: ComponentType;
props: Record<string, unknown>;
style: React.CSSProperties;
children?: DesignerComponent[];
parentId?: string;
}
export interface DataField {
name: string;
label: string;
type: 'string' | 'number' | 'date' | 'boolean' | 'select';
required?: boolean;
options?: { label: string; value: string | number }[];
}
export interface DataModel {
id: string;
name: string;
nameEn: string;
fields: DataField[];
}
export interface User {
id: string;
name: string;
email: string;
phone: string;
department: string;
status: 'active' | 'inactive';
role: string;
createdAt: string;
updatedAt: string;
}
export type Language = 'zh' | 'en';
export interface Locale {
[key: string]: {
zh: string;
en: string;
};
}

1
src/vite-env.d.ts vendored Normal file
View File

@ -0,0 +1 @@
/// <reference types="vite/client" />

29
tailwind.config.js Normal file
View File

@ -0,0 +1,29 @@
/** @type {import('tailwindcss').Config} */
export default {
content: [
"./index.html",
"./src/**/*.{js,ts,jsx,tsx}",
],
theme: {
extend: {
colors: {
primary: {
50: '#eff6ff',
100: '#dbeafe',
200: '#bfdbfe',
300: '#93c5fd',
400: '#60a5fa',
500: '#3b82f6',
600: '#2563eb',
700: '#1d4ed8',
800: '#1e40af',
900: '#1e3a8a',
}
}
},
},
plugins: [],
corePlugins: {
preflight: false,
}
}

24
tsconfig.json Normal file
View File

@ -0,0 +1,24 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]
}
},
"include": ["src"]
}

16
vite.config.ts Normal file
View File

@ -0,0 +1,16 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import path from 'path'
export default defineConfig({
plugins: [react()],
resolve: {
alias: {
'@': path.resolve(__dirname, './src')
}
},
server: {
port: 3000,
open: true
}
})