feat: 初始化低代码平台前端Demo项目
- 实现拖拽组件库(输入框、下拉选择、表格、按钮等12种组件) - 实现画布拖放和组件排序功能 - 实现属性配置面板(基础属性和样式配置) - 实现用户管理CRUD完整功能(增删改查、搜索筛选) - 支持中英文国际化切换 - 支持设计器/预览双模式切换 - 使用React 18 + TypeScript + Vite + Ant Design + dnd-kit + Zustand技术栈
This commit is contained in:
commit
24528db0f7
|
|
@ -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
|
||||||
|
|
@ -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>
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,6 @@
|
||||||
|
export default {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,2 @@
|
||||||
|
export { CanvasArea } from './CanvasArea';
|
||||||
|
export { ComponentRenderer } from './ComponentRenderer';
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
};
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -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
|
||||||
|
})
|
||||||
|
}
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
@ -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;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
/// <reference types="vite/client" />
|
||||||
|
|
@ -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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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"]
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
})
|
||||||
Loading…
Reference in New Issue