diff --git a/.alias.js b/.alias.js new file mode 100644 index 0000000..d7b012b --- /dev/null +++ b/.alias.js @@ -0,0 +1,9 @@ +const path = require('path'); + +module.exports = { + resolve: { + alias: { + '@': path.resolve(__dirname), + }, + }, +}; diff --git a/.eslintrc.json b/.eslintrc.json index bffb357..970746f 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -1,3 +1,4 @@ { - "extends": "next/core-web-vitals" + "extends": "next/core-web-vitals", + "plugins": ["react", "react-hooks"] } diff --git a/.gitignore b/.gitignore index 8a23a77..c79be14 100644 --- a/.gitignore +++ b/.gitignore @@ -35,3 +35,4 @@ yarn-error.log* pnpm-lock.yaml package-lock.json +data/sqlite.db* diff --git a/.node-version b/.node-version index f0b10f1..3f430af 100644 --- a/.node-version +++ b/.node-version @@ -1 +1 @@ -v16.13.1 +v18 diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 0000000..bf26936 --- /dev/null +++ b/.prettierignore @@ -0,0 +1,11 @@ +**/*.svg +package.json +out +.DS_Store +*.png +.editorconfig +Dockerfile* +.gitignore +.prettierignore +LICENSE +.next diff --git a/.prettierrc.yaml b/.prettierrc.yaml index d5a7331..a3f4824 100644 --- a/.prettierrc.yaml +++ b/.prettierrc.yaml @@ -1,9 +1,9 @@ # .prettierrc tabWidth: 4 -Tabs: false semi: true singleQuote: true trailingComma: 'es5' bracketSpacing: true jsxBracketSameLine: false arrowParens: 'avoid' +printWidth: 100 diff --git a/Dockerfile b/Dockerfile index 7490227..7d11bef 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM node:16-alpine +FROM node:18-alpine # Check https://github.com/nodejs/docker-node/tree/b4117f9333da4138b03a546ec926ef50a31506c3#nodealpine to understand why libc6-compat might be needed. RUN apk add --no-cache libc6-compat diff --git a/README-CN.md b/README-CN.md index 2e28d03..6b8b7ef 100644 --- a/README-CN.md +++ b/README-CN.md @@ -12,7 +12,7 @@ https://dber.tech 1. 可视化数据库结构设计 2. 拖拽生成模型引用关系 -3. 一键导出SQL语句 +3. 一键导出 SQL 语句 ## 技术栈 @@ -26,6 +26,8 @@ ArcoDesign Dexie(indexDB) +Soul CLI(sqlite db) + ## 开始 克隆本仓库或者下载代码. @@ -60,15 +62,30 @@ npm run build && npm run start npm run gen ``` -## 使用docker构建 +避免刷新时出现 404,服务器需做以下设置(以 `Nginx` 为例): + +``` +server { + listen 80; + server_name dber.local.yes-hr.com; + root /{you_projects}/dber/out; + index index.html; + + location /graphs { + try_files $uri $uri.html /graphs/[id].html; + } +} +``` + +## 使用 docker 构建 -使用以下命令来构建Docker镜像: +使用以下命令来构建 Docker 镜像: ``` docker build -t dber . ``` -然后可以用Docker或者Docker Compose来启动服务: +然后可以用 Docker 或者 Docker Compose 来启动服务: ``` docker run -p 3000:3000 dber @@ -82,6 +99,17 @@ docker-compose up -d 使用浏览器打开 [http://localhost:3000](http://localhost:3000) 查看结果. +## 协作(简单)功能 + +使用 [Soul CLI](https://github.com/thevahidal/soul) 实现简单的在线协作功能,使用的是 `sqlite` 数据库。注意: + +- 暂时没有权限管理功能,如数据库接口公开,意味着任何人都具有读取、写入权限 +- 不支持编辑操作后的实时同步功能 +- 启动方式: + - 安装 Soul CLI 包 `np install -D soul-cli` + - 编辑 `package.json` 中的 `dbAdaptor` 为 `soul`,并根据实际情况设置 `soulUrl` + - 执行 `npm run dev` 或者 `npm run build && npm run start` (docker 方式未经测试) + ## 受到以下作品启发 [dbdiagram](https://dbdiagram.io/) diff --git a/components/context_menu.js b/components/context_menu.js index d69064a..64fce2f 100644 --- a/components/context_menu.js +++ b/components/context_menu.js @@ -1,44 +1,67 @@ -import { Space } from '@arco-design/web-react'; -import { Menu, Item, Separator, theme } from 'react-contexify'; -import 'react-contexify/dist/ReactContexify.css'; +import { Dropdown, Menu, Space, Divider } from '@arco-design/web-react'; + +import graphState from '@/hooks/use-graph-state'; +import tableModel from '@/hooks/table-model'; + +export default function ContextMenu({ setShowModal, children }) { + const { version } = graphState.useContainer(); + const { updateGraph, addTable } = tableModel(); + + const menus = [ + { + key: 'N', + title: 'Add New Table', + action: () => addTable(), + }, + { + key: 'I', + title: 'Import Table', + action: () => setShowModal('import'), + }, + { + key: 'line', + }, + { + key: 'S', + title: 'Save Change', + action: () => updateGraph(), + }, + { + key: 'E', + title: 'Export Database', + action: () => setShowModal('export'), + }, + ]; -export default function ContextMenu(props) { return ( - - { - props.addTable({ x: triggerEvent.clientX - 40, y: triggerEvent.clientY - 100 }); - }} - style={{ justifyContent: 'center' }} - > - Add New Table - -
-
N
-
-
- props.setImportType('MySQL')}> - Import Table - -
-
I
-
-
- - props.saveGraph()}> - Save - -
-
S
-
-
- props.handlerExport()}> - Export Database - -
-
E
-
-
-
+ + {menus.map(item => + item.key === 'line' ? ( + + ) : ( + + {item.title} + +
+
{item.key}
+
+
+ ) + )} + + ) + } + > + {children} +
); } diff --git a/components/export_modal.js b/components/export_modal.js index 66a8e18..278e701 100644 --- a/components/export_modal.js +++ b/components/export_modal.js @@ -1,16 +1,24 @@ +import { useState, useEffect } from 'react'; import { Modal, Notification, Tabs } from '@arco-design/web-react'; import Editor from '@monaco-editor/react'; +import graphState from '@/hooks/use-graph-state'; +import exportSQL from '@/utils/export-sql'; + const TabPane = Tabs.TabPane; /** * It's a modal that displays the command to be exported * @returns Modal component */ -export default function ExportModal({ command, handlerExport, exportType, theme }) { +export default function ExportModal({ showModal, onCloseModal }) { + const [exportType, setExportType] = useState('dbml'); + const [sqlValue, setSqlValue] = useState(''); + const { tableDict, linkDict, theme } = graphState.useContainer(); + const copy = async () => { try { - await window.navigator.clipboard.writeText(command); + await window.navigator.clipboard.writeText(sqlValue); Notification.success({ title: 'Copy Success', }); @@ -21,37 +29,43 @@ export default function ExportModal({ command, handlerExport, exportType, theme }); } }; + + useEffect(() => { + if (showModal === 'export') { + const sql = exportSQL(tableDict, linkDict, exportType); + setSqlValue(sql); + } + }, [showModal, exportType]); + return ( copy()} okText="Copy" cancelText="Close" - onCancel={() => handlerExport('')} + onCancel={() => onCloseModal()} style={{ width: 'auto' }} > - handlerExport(val)} - > + setExportType(val)}> - + { + const save = values => { const data = { ...field, ...values }; - table.fields = table.fields.map(f => f.id === data.id ? data : f); - props.updateTable(table); - }; - - const submit = () => { - form.submit(); + table.fields = table.fields.map(f => (f.id === data.id ? data : f)); + updateTable(table); }; - return ( + return table ? ( Edit {table ? ( - {table.name} + + {table.name} + ) : ( '' )} Field } - visible={table} + visible={!!table} onCancel={() => { - if (addField?.index) { - removeField(addField.table, addField.index); + if (addingField?.index) { + removeField(addingField.table, addingField.index); } - setEditingField(false); + setEditingField({}); }} onOk={() => { - setAddField(null); - submit(); + setAddingField(null); + form.submit(); }} escToExit={!formChange} maskClosable={!formChange} afterClose={() => { - setFormChange(false); + onFormChange(false); + }} + afterOpen={() => { + form.resetFields(); }} style={{ width: 580 }} okText="Commit" @@ -64,7 +70,7 @@ export default function FieldForm(props) { labelCol={{ span: 6 }} wrapperCol={{ span: 18 }} onValuesChange={(changedValues, allValues) => { - if (!formChange) setFormChange(true); + if (!formChange) onFormChange(true); }} > @@ -73,7 +79,21 @@ export default function FieldForm(props) { label="Name" field="name" initialValue={field.name} - rules={[{ required: true, message: 'Please enter field name' }]} + rules={[ + { + required: true, + message: 'Please enter field name', + }, + { + validator: (value, cb) => { + return table.fields + .filter(item => item.id !== field.id) + .find(item => item.name === value) + ? cb('have same name field') + : cb(); + }, + }, + ]} > @@ -81,22 +101,25 @@ export default function FieldForm(props) { label="Type" field="type" initialValue={field.type} - rules={[{ required: true, message: 'Please choose field type' }]} + rules={[ + { + required: true, + message: 'Please choose field type', + }, + ]} > - + - + @@ -118,5 +141,5 @@ export default function FieldForm(props) { )} - ); + ) : null; } diff --git a/components/history.js b/components/history.js deleted file mode 100644 index 39b3279..0000000 --- a/components/history.js +++ /dev/null @@ -1,89 +0,0 @@ -import { Button, Drawer, Notification, Popconfirm, Space } from '@arco-design/web-react'; - -import { db } from '../data/db'; - -export default function HistoryDrawer(props) { - const { history, setHistory, version, handlerVersion } = props; - - const deleteHistory = (e, id) => { - e.preventDefault(); - e.stopPropagation(); - - db.logs.delete(id); - props.handlerHistory(); - Notification.success({ - title: 'Delete history record success', - }); - }; - - return ( - { - setHistory(undefined); - handlerVersion('currentVersion'); - }} - style={{ boxShadow: '0 0 8px rgba(0, 0, 0, 0.1)' }} - > - handlerVersion('currentVersion')} - > -
-
-
- Current Version -
-
-
- - {history ? history.map(item => ( - handlerVersion(item)} - > -
-
-
- Version {item.id} -
-
- Auto save at {new Date(item.updatedAt).toLocaleString()} -
-
- { - deleteHistory(e, item.id); - }} - onCancel={e => { - e.preventDefault(); - e.stopPropagation(); - }} - > - { - e.preventDefault(); - e.stopPropagation(); - }} - > - [DELETE] - - -
- )) : null} -
- ); -}; diff --git a/components/import_modal.js b/components/import_modal.js index f1b803f..a978298 100644 --- a/components/import_modal.js +++ b/components/import_modal.js @@ -4,33 +4,43 @@ import { useState } from 'react'; import { nanoid } from 'nanoid'; import Editor from '@monaco-editor/react'; +import graphState from '@/hooks/use-graph-state'; +import tableModel from '@/hooks/table-model'; + const TabPane = Tabs.TabPane; /** * It's a modal that allows you to import a graph from a string * @returns Modal component */ -export default function ImportModal({ importType, setImportType, theme, handlerImportTable }) { +export default function ImportModal({ showModal, onCloseModal, cb = p => {} }) { + const { theme, setTableDict, setLinkDict, tableList } = graphState.useContainer(); + const { calcXY } = tableModel(); + const [value, setValue] = useState(''); + const [importType, setImportType] = useState('dbml'); const handleOk = async () => { if (!value) { - setImportType(''); + onCloseModal(); return; } try { - const result = await Parser.parse(value, importType.toLowerCase()); + const result = await Parser.parse(value, importType); const graph = result.schemas[0]; const tableDict = {}; const linkDict = {}; + const tables = [...tableList]; graph.tables.forEach((table, index) => { const id = nanoid(); - tableDict[id] = { + const [x, y] = calcXY(0, tables); + const newTable = { id, name: table.name, note: table.note, - x: index * 260 + 60, - y: 120, + theme: table.headerColor, + x, + y, fields: table.fields.map(field => { const fieldId = nanoid(); return { @@ -42,9 +52,12 @@ export default function ImportModal({ importType, setImportType, theme, handlerI pk: field.pk, unique: field.unique, type: field.type.type_name.toUpperCase(), + dbdefault: field.dbdefault?.value, }; }), }; + tableDict[id] = newTable; + tables.push(newTable); }); graph.refs.forEach(ref => { @@ -66,9 +79,18 @@ export default function ImportModal({ importType, setImportType, theme, handlerI }; }); - handlerImportTable({ tableDict, linkDict }); + setTableDict(state => ({ + ...state, + ...tableDict, + })); + setLinkDict(state => ({ + ...state, + ...linkDict, + })); + setValue(''); - setImportType(''); + onCloseModal(); + cb({ tableDict, linkDict }); } catch (e) { console.log(e); Notification.error({ @@ -81,34 +103,30 @@ export default function ImportModal({ importType, setImportType, theme, handlerI { - handleOk(); - }} + onOk={() => handleOk()} okText="Import" cancelText="Close" - onCancel={() => setImportType('')} + onCancel={() => onCloseModal()} style={{ width: 'auto' }} unmountOnExit > - setImportType(val)} - > - - - - + setImportType(val)}> + + + + { const { linkId, fieldId } = editingLink; setLinkDict(state => { @@ -34,18 +38,20 @@ export default function LinkModal(props) { }); setEditingLink(null); }; + const removeLink = () => { - const { linkId, fieldId } = editingLink; + const { linkId } = editingLink; setLinkDict(state => { delete state[linkId]; return { ...state }; }); setEditingLink(null); }; + return ( setEditingLink(null)} footer={null} autoFocus={false} diff --git a/components/link_path.js b/components/link_path.js index 185c1d6..280a479 100644 --- a/components/link_path.js +++ b/components/link_path.js @@ -1,66 +1,60 @@ +import graphState from '@/hooks/use-graph-state'; +import { tableWidth, fieldHeight, commentHeight, titleHeight } from '@/config/settings'; + const control = 20; const padding = 5; const gripWidth = 10; const gripRadius = gripWidth / 2; -const margin = 3; +const margin = 0.5; /** * It takes a link object and returns a path element that connects the two tables * @param props - The props object that is passed to the component. * { * link, - * tableDict, - * linkDict, - * TableWidth, * setEditingLink, * } * @returns A svg path is being returned. */ export default function LinkPath(props) { - const { - link, - tableDict, - linkDict, - TableWidth: width, - setEditingLink, - editable, - } = props; + const { link, setEditingLink } = props; + const { tableDict, version } = graphState.useContainer(); + + const editable = version === 'currentVersion'; + if (!tableDict) return null; const { endpoints } = link; - const [sourceTable, targetTable] = [ - tableDict[endpoints[0].id], - tableDict[endpoints[1].id], - ]; + const [sourceTable, targetTable] = [tableDict[endpoints[0].id], tableDict[endpoints[1].id]]; const [sourceFieldIndex, targetFieldIndex] = [ sourceTable.fields.findIndex(field => field.id === endpoints[0].fieldId), targetTable.fields.findIndex(field => field.id === endpoints[1].fieldId), ]; + const calcHeight = titleHeight + commentHeight + fieldHeight / 2; + const sourceFieldPosition = { x: sourceTable.x, - y: sourceTable.y + sourceFieldIndex * 32 + 50 + gripRadius + 24, + y: sourceTable.y + sourceFieldIndex * fieldHeight + calcHeight, ...endpoints[0], }; const targetFieldPosition = { x: targetTable.x, - y: targetTable.y + targetFieldIndex * 32 + 50 + gripRadius + 24, + y: targetTable.y + targetFieldIndex * fieldHeight + calcHeight, ...endpoints[1], }; // const [source, target] = [sourceFieldPosition, targetFieldPosition]; - const [source, target] = [sourceFieldPosition, targetFieldPosition].sort( - (a, b) => { - return a.x - b.x || a.y - b.y; - } - ); + const [source, target] = [sourceFieldPosition, targetFieldPosition].sort((a, b) => { + return a.x - b.x || a.y - b.y; + }); const sourceLeft = source.x + padding + gripRadius + margin; - const sourceRight = source.x + width - padding - gripRadius - margin; + const sourceRight = source.x + tableWidth - padding - gripRadius - margin; let x = sourceLeft; @@ -68,7 +62,7 @@ export default function LinkPath(props) { const targetLeft = target.x + padding + gripRadius + margin; - const targetRight = target.x + width - padding - gripRadius - margin; + const targetRight = target.x + tableWidth - padding - gripRadius - margin; let minDistance = Math.abs(sourceLeft - targetLeft); @@ -95,20 +89,42 @@ export default function LinkPath(props) { e.stopPropagation(); }; + let d = `M ${x} ${y} + C ${x + control} ${y} ${midX} ${midY} ${midX} ${midY} + C ${midX} ${midY} ${x1 - control} ${y1} ${x1} ${y1}`; + let foreignObjectPositions = [ + { + x: (x + control + midX) / 2 - 10, + y: (y + midY) / 2 - 10, + }, + { x: (x1 - control + midX) / 2 - 10, y: (y1 + midY) / 2 - 10 }, + ]; + + if (endpoints[0].id === endpoints[1].id) { + const factor = (y1 - y) / 50 < 2 ? 2 : (y1 - y) / 50; + + d = `M ${sourceRight} ${y} + L ${sourceRight + control} ${y} + C ${sourceRight + control * factor} ${y} ${sourceRight + control * factor} ${y1} ${ + sourceRight + control + } ${y1} + L ${sourceRight} ${y1}`; + + foreignObjectPositions = [ + { + x: sourceRight + control - 10, + y: y - 10, + }, + { x: targetRight + control - 10, y: y1 - 10 }, + ]; + } + return ( <> - + { @@ -121,15 +137,18 @@ export default function LinkPath(props) { onContextMenu={handlerContextMenu} >
{source.relation}
{ @@ -142,7 +161,10 @@ export default function LinkPath(props) { onContextMenu={handlerContextMenu} >
{target.relation} diff --git a/components/list_nav.js b/components/list_nav.js index 39a2db7..95c98be 100644 --- a/components/list_nav.js +++ b/components/list_nav.js @@ -1,6 +1,8 @@ -import { Space, Button, Dropdown, Menu, Switch } from '@arco-design/web-react'; -import { IconSunFill, IconMoonFill } from '@arco-design/web-react/icon'; import Link from 'next/link'; +import { Space, Button, Switch } from '@arco-design/web-react'; +import { IconSunFill, IconMoonFill } from '@arco-design/web-react/icon'; + +import graphState from '@/hooks/use-graph-state'; /** * It renders a nav bar with a link to the home page, a button to add a new graph, and a dropdown menu @@ -8,75 +10,31 @@ import Link from 'next/link'; * @param props - the props passed to the component * @returns List Nav component */ -export default function ListNav(props) { - const { setImportType } = props; +export default function ListNav({ importGraph, addGraph, addExample }) { + const { theme, setTheme } = graphState.useContainer(); return (
- - DBER | Database design tool based on - entity relation diagram - + DBER | Database design tool based on entity relation diagram
- - { - setImportType('DBML'); - }} - > - DBML - - { - setImportType('PostgreSQL'); - }} - > - PostgreSQL - - { - setImportType('MySQL'); - }} - > - MySQL - - { - setImportType('MSSQL'); - }} - > - MSSQL - - - } - position="br" - > - - - + + } uncheckedIcon={} - checked={props.theme === 'dark'} - onChange={(e) => { - props.setTheme(e ? 'dark' : 'light'); - }} + checked={theme === 'dark'} + onChange={e => setTheme(e ? 'dark' : 'light')} />
diff --git a/components/logs.js b/components/logs.js new file mode 100644 index 0000000..914cefe --- /dev/null +++ b/components/logs.js @@ -0,0 +1,100 @@ +import { useState, useEffect } from 'react'; +import { Drawer, Notification, Popconfirm, Space } from '@arco-design/web-react'; + +import { delLogs, getLogs } from '@/engine/db'; +import graphState from '@/hooks/use-graph-state'; +import tableModel from '@/hooks/table-model'; + +export default function LogsDrawer({ showDrawer, onCloseDrawer }) { + const { id, version } = graphState.useContainer(); + const { applyVersion } = tableModel(); + const [logs, setLogs] = useState(undefined); + + useEffect(() => { + if (showDrawer === 'logs') { + (async () => { + const records = await getLogs(id); + setLogs(records); + })(); + } + }, [id, showDrawer]); + + const deleteLogs = async (e, id) => { + e.preventDefault(); + e.stopPropagation(); + + await delLogs(id); + setLogs(state => state.filter(item => item.id !== id)); + Notification.success({ + title: 'Delete logs record success', + }); + }; + + return ( + onCloseDrawer()} + style={{ boxShadow: '0 0 8px rgba(0, 0, 0, 0.1)' }} + > + applyVersion('currentVersion')} + > +
+
+
Current Version
+
+
+ + {logs + ? logs.map(item => ( + applyVersion(item)} + > +
+
+
Version {item.id}
+
+ Auto save at {new Date(item.updatedAt).toLocaleString()} +
+
+ deleteLogs(e, item.id)} + onCancel={e => { + e.preventDefault(); + e.stopPropagation(); + }} + > + { + e.preventDefault(); + e.stopPropagation(); + }} + > + [DELETE] + + +
+ )) + : null} +
+ ); +} diff --git a/components/nav.js b/components/nav.js index c134b05..ee16061 100644 --- a/components/nav.js +++ b/components/nav.js @@ -1,12 +1,9 @@ -import { - Button, - Space, - Popconfirm, - Input, - Switch, -} from '@arco-design/web-react'; -import { IconSunFill, IconMoonFill, IconLeft } from '@arco-design/web-react/icon'; import Link from 'next/link'; +import { Button, Space, Popconfirm, Input, Switch, Dropdown, Menu } from '@arco-design/web-react'; +import { IconSunFill, IconMoonFill, IconLeft } from '@arco-design/web-react/icon'; + +import graphState from '@/hooks/use-graph-state'; +import tableModel from '@/hooks/table-model'; /** * It renders a nav bar with a title, a save button, a demo button, a clear button, an export button, @@ -14,22 +11,31 @@ import Link from 'next/link'; * @param props - the props passed to the component * @returns A Nav component that takes in a title, a save button, a demo button, a clear button, an export button */ -export default function Nav(props) { - if (!props.editable) { +export default function Nav({ setShowModal, setShowDrawer }) { + const { name, setName, theme, setTheme, setTableDict, setLinkDict, version } = + graphState.useContainer(); + const { updateGraph, addTable, applyVersion } = tableModel(); + + if (version !== 'currentVersion') { return ( @@ -40,62 +46,86 @@ export default function Nav(props) { diff --git a/components/select_input.js b/components/select_input.js index cb5c065..d5a4298 100644 --- a/components/select_input.js +++ b/components/select_input.js @@ -17,12 +17,7 @@ export default function SelectInput({ name, options, defaultValue, width }) { return ( <> - {options.map(item => (