集成低代码平台并实现版本控制——从零开始搭建一个高颜值后台管理系统全栈框架(十七)

前言

现在市面上开源的低代码平台很多,但这些开源的平台都只包含了低代码部分,如果有人想用这些低代码平台开发功能,必须自己先实现一些基础功能,比如登录注册、菜单管理、权限管理等,实现完这些基础功能后还要自己去对接低代码功能,反正用起来比较麻烦。

而我想做的就是一套开箱即用的低代码平台,可以帮助个人开发者快速做出一个产品,可以帮助产品经理快速做出一个原型去做市场验证,可以帮助接私活的兄弟快速完成任务。

后面会带着大家一点点完成这个全栈项目,让大家看完后,可以自己也做出一个低代码平台,虽然现在很多人排斥低代码平台,但是你做过低代码平台,了解低代码的运行原理,简历上还是会有一些加分的。

这一篇的目标是把我们前面做的低代码功能给迁移到fluxy-admin平台中,并且把低代码创建的页面对接到系统菜单中,通过菜单可以访问低代码创建的页面。还会实现低代码页面版本管理,支持版本发布、回滚等功能。

没看过我前面写的低代码基础知识入门的朋友,建议先看一下。

低代码入门知识详解

题外话

这里说一下我为啥把自己的开发记录分享出来

一是因为我是一个自制力比较差的人,很多事情都是半途而废,说实话去年写fluxy-admin基础功能的时候,有段时间停了很久,差点没坚持下去,但是我看到评论区有些人催更,还有些点赞鼓励,我咬着牙坚持下去,最终给完结了。

二是因为授人以鱼不如授人以渔,我做出来一个平台给大家用,不如教大家自己做一个低代码平台,有些刚入门的同学,跟着我的教程一点点做完,也算有了自己的实战项目了。

实战

前期准备

创建目录

在pages下面创建low-code文件夹,后面低代码相关的页面都放这里面。

再创建一个page文件夹,表示低代码页面配置。

在page文件夹下创建list和new文件夹分别表示低代码页面列表页和低代码配置页面。

创建菜单

先创建一个低代码平台目录,接着再创建一个低代码页面管理菜单,这个页面主要用来管理低代码创建出来的页面,然后再创建一个低代码页面配置菜单,用来创建低代码页面。

低代码迁移

把前面做的低代码demo给迁移到项目中,虽然前面做的很粗糙,但是基本功能都有,后面在这个基础上慢慢优化。

前期先以组件的形式放在项目里,后面会把低代码做成独立组件发布成npm包,这样做的好处是低代码和基础平台节藕,方便其他平台使用。很多开源项目都是这样出来的,先内部沉淀,随着功能越来越丰富,把一些基础功能拆出去开源。

在src/components文件夹下创建low-code文件夹,把lowcode-demo项目里的editor文件夹复制到low-code文件夹中。

src/pages/low-code/page/new/index.tsx引入低代码编辑器

tsx 复制代码
// src/pages/low-code/page/new/index.tsx
import Layout from '@/components/low-code/editor/layouts';
import { KeepAliveTabContext } from '@/layouts/tabs-context';
import { useContext } from 'react';
import { DndProvider } from 'react-dnd';
import { HTML5Backend } from 'react-dnd-html5-backend';
import { useNavigate } from 'react-router-dom';

const LowCodePageNew = () => {

  const navigate = useNavigate();
  const keepAliveTab = useContext(KeepAliveTabContext);

  const onBack = () => {
    // 因为打开当前页面会打开一个页签,后退的时候需要关闭这个页签
    keepAliveTab.closeTab();
    navigate('/low-code/page');
  }

  return (
    <div className='w-full bg-container h-full fixed top-0 bottom-0 z-1000 right-0 left-0 bg-white'>
      <DndProvider backend={HTML5Backend}>
        <Layout onBack={onBack} />
      </DndProvider>
    </div>
  )
}

export default LowCodePageNew;

在列表页面先加一个创建页面按钮,可以跳到编辑页面。

tsx 复制代码
// src/pages/low-code/page/list/index.tsx

import { Button } from 'antd';
import { useNavigate } from 'react-router-dom';

const LowCodePageList = () => {

  const navigate = useNavigate();

  return (
    <div>
      <Button
        onClick={() => {
          navigate('/low-code/page/new-page');
        }}
        type='primary'
      >
        创建页面
      </Button>
    </div>
  )
}

export default LowCodePageList;

因为需要支持暗黑模式,调整了一些元素背景颜色,这一步很简单所以省略了。

效果展示

保存为系统菜单

前言

假如我们用低代码创建出了一个页面后,怎么和系统菜单绑定呢,下面我们来实现一下。

实现思路

在工具栏加一个保存按钮,点击保存时,弹出一个表单,这个表单和新建菜单的表单差不多,只不过这里不能选择类型,写死菜单类型为4(低代码页面),并且可以选择上级菜单。

填完菜单信息后,点保存按钮调用后端新建菜单接口并把填的菜单信息和低代码数据传给接口,后端接口判断一下菜单类型如果为低代码页面类型,则把传过来低代码内容保存成文件上传到文件服务器,文件名为当前菜单id。

这里为什么把低代码内容保存成文件,而不是直接存到数据库中,有以下好处:

  • 前端请求的时候,因为是文件服务和后端服务是分开的,可以减少后端服务压力。
  • 前端请求的时候,静态文件可以更好的使用浏览器缓存,虽然接口也可以配置缓存,但是还要单独配置,比较麻烦。
  • 前端请求的时候,文件走cdn很简单,现在云服务器运营商的对象存储都支持cdn。

前端实现

给工具栏中添加一个保存按钮,对外暴露保存事件。new/index.tsx中监听保存事件,然后弹出弹框填写菜单信息,最后保存到后端。

填写菜单信息的弹框可以把以前开发过的添加菜单的弹框代码复制过来改一改,需要注意的时候,最后保存的时候,需要把菜单类型写死为低代码页面类型。

tsx 复制代码
// src/pages/low-code/page/new/new-page-modal.tsx

import { antdIcons } from '@/assets/antd-icons';
import { useVariablesStore } from '@/components/low-code/editor/stores/variable';
import { useRequest } from '@/hooks/use-request';
import { MenuType } from '@/pages/menu/interface';
import menuService, { Menu } from '@/pages/menu/service';
import roleService from '@/pages/role/service';
import { antdUtils } from '@/utils/antd';
import { Form, Input, InputNumber, Modal, Select, Switch, TreeSelect } from 'antd';
import type { DataNode } from 'antd/es/tree';
import React, { useEffect, useState } from 'react';
import { useComponentsStore } from '../../../../components/low-code/editor/stores/components';

interface Props {
  open: boolean;
  setOpen: (open: boolean) => void;
}

const NewPageModal: React.FC<Props> = ({
  open,
  setOpen,
}) => {

  const [treeData, setTreeData] = useState<DataNode[]>([]);
  const { components } = useComponentsStore();
  const { variables } = useVariablesStore();

  const {
    runAsync,
    loading: saveLoading,
  } = useRequest(menuService.addMenu, { manual: true });

  const [form] = Form.useForm();

  const formatTree = (roots: Menu[] = [], group: Record<string, Menu[]>): DataNode[] => {
    return roots.map((node) => {
      return {
        value: node.id,
        label: node.name,
        key: node.id,
        title: node.name,
        children: formatTree(group[node.id] || [], group),
      } as DataNode;
    });
  };

  const getData = async () => {
    const [error, data] = await roleService.getAllMenus();

    if (!error) {
      const group = data.reduce<Record<string, Menu[]>>((prev, cur) => {
        if (!cur.parentId) {
          return prev;
        }

        if (prev[cur.parentId]) {
          prev[cur.parentId].push(cur);
        } else {
          prev[cur.parentId] = [cur];
        }
        return prev;
      }, {});

      const roots = data.filter((o) => !o.parentId);

      const newTreeData = formatTree(roots, group);
      setTreeData(newTreeData);
    }

  };

  const save = async (values: any) => {

    // 写死菜单类型为低代码页面
    values.type = MenuType.LowCodePage;
    // 把组件和变量转成字符串,传给后端
    values.pageSetting = JSON.stringify({ components, variables });

    const [error] = await runAsync(values);
    if (!error) {
      antdUtils.message?.success('分配成功');
      setOpen(false);
    }
  };

  useEffect(() => {
    getData();
  }, []);

  return (
    <Modal
      title="保存页面"
      open={open}
      onCancel={() => { setOpen(false); }}
      width={640}
      onOk={() => { form.submit(); }}
      confirmLoading={saveLoading}
    >
      <Form
        form={form}
        onFinish={save}
        labelCol={{ flex: '0 0 100px' }}
        wrapperCol={{ span: 16 }}
        initialValues={{ show: true }}
      >
        <Form.Item
          rules={[{
            required: true,
            message: '不能为空',
          }]}
          label="上级菜单"
          name="parentId"
        >
          <TreeSelect treeData={treeData} />
        </Form.Item>
        <Form.Item
          rules={[{
            required: true,
            message: '不能为空',
          }]}
          label="名称"
          name="name"
        >
          <Input />
        </Form.Item>
        <Form.Item label="图标" name="icon">
          <Select>
            {Object.keys(antdIcons).map((key) => (
              <Select.Option key={key}>{React.createElement(antdIcons[key])}</Select.Option>
            ))}
          </Select >
        </Form.Item>
        <Form.Item
          tooltip="以/开头,不用手动拼接上级路由。参数格式/:id"
          label="路由"
          name="route"
          rules={[{
            pattern: /^\//,
            message: '必须以/开头',
          }, {
            required: true,
            message: '不能为空',
          }]}
        >
          <Input />
        </Form.Item>
        <Form.Item valuePropName="checked" label="是否显示" name="show">
          <Switch />
        </Form.Item>
        <Form.Item label="排序号" name="orderNumber">
          <InputNumber />
        </Form.Item>
      </Form>
    </Modal >
  )
}

export default NewPageModal;

后端实现

首先给菜单实体添加一个低代码页面当前版本号字段

再改造一下新建菜单的逻辑,如果菜单类型为低代码页面,默认版本为v1.0.0,然后把低代码页面配置信息保存成json文件上传到minio文件服务器。

渲染低代码页面

实现思路

在动态创建路由的时候,判断菜单类型如果为低代码页面类型,就去加载低代码渲染组件,把当前页面id和版本号传过去,低代码渲染组件中,根据页面id和版本号,请求低代码页面信息,拿到信息后渲染低代码页面。

改造动态路由

添加低代码渲染器组件

tsx 复制代码
// src/components/low-code/renderer/index.tsx

import ProdStage from '@/components/low-code/editor/layouts/stage/prod';
import { Spin } from 'antd';
import { useEffect, useRef, useState } from 'react';

const LowCodeRenderer = ({ pageId, version }: { pageId?: string, version?: string }) => {

  const [loading, setLoading] = useState(true);

  const [components, setComponents] = useState([]);

  // 存放已经加载过的组件配置
  const loadedComponents = useRef(new Map());

  const loadComponents = async () => {

    const url = `/file/low-code/${pageId}/${version}.json`;

    // 已经加载过,直接返回
    if (loadedComponents.current.has(url)) {
      setComponents(loadedComponents.current.get(url));
      return;
    }

    // 获取组件配置
    const data = await window.fetch(`/file/low-code/${pageId}/${version}.json`)
      .then(res => res.json());
    
    loadedComponents.current.set(url, data.components);
    setComponents(data.components);
  }

  const init = async () => {
    setLoading(true);
    await loadComponents();
    setLoading(false);
  }

  useEffect(() => {
    if (pageId && version) {
      init();
    }
  }, [
    pageId,
    version,
  ]);

  if (loading || !pageId || !version) {
    return (
      <Spin />
    )
  }

  return (
    <ProdStage components={components} />
  )
}

export default LowCodeRenderer;

效果展示

这里我使用的是超级管理员账号,默认拥有所有菜单权限,正常用户需要分配才能看到菜单。

到这里我们已经给低代码页面对接到了系统中,可以正常的创建和展示低代码页面。

实现多版本

前言

如果我们使用低代码开发了一个页面,测试完成后,上线了,有一天突然加了一个需求,然后在当前版本去修改,修改测试完发布到线上,突然出现了一个很严重的bug,需要回滚到上一个版本,这时候就需要多版本了,下面我们来实现一下。

后端实现

加一个版本实体

ts 复制代码
// src/module/menu/entity/menu.version.ts

import { Column, Entity } from 'typeorm';
import { BaseEntity } from '../../../common/base.entity';

@Entity('sys_menu_version')
export class MenuVersionEntity extends BaseEntity {
  @Column({ comment: '菜单id' })
  menuId?: string;
  @Column({ comment: '版本号' })
  version?: string;
  @Column({ comment: '版本描述' })
  description?: string;
}

新增低代码页面的时候,初始化一个默认版本

增加查询低代码页面接口

更新版本

就是用新的页面配置覆盖老的

创建新版本

实现发布功能

发布功能很简单,就是把菜单上的版本号改一下就行了。

前端实现

列表页实现

列表页面使用了表格嵌套,外面是菜单,里面一层是版本。

代码实现

当前版本不能编辑和发布,但是都可以复制

编辑页

这里的代码比较多就不一一截图了,都是业务代码很简单。

核心就是编辑页保存支持更新、保存成新版本和新页面。

复制页

和编辑差不多,只不过不能更新,可以保存为新版本和新页面。

最后

这一篇主要是把低代码迁移进来了,实现的功能比较基础,后面我们一点点去完善。

项目体验地址:www.fluxyadmin.cn/user/login

前端仓库地址:github.com/dbfu/fluxy-...

后端仓库地址:github.com/dbfu/fluxy-...

相关推荐
代码小鑫31 分钟前
A035-基于Spring Boot的企业内管信息化系统
java·开发语言·spring boot·后端·spring
Spy9734 分钟前
django 过滤器的执行
后端·python·django
前端郭德纲2 小时前
浅谈React的虚拟DOM
前端·javascript·react.js
2401_879103683 小时前
24.11.10 css
前端·css
ComPDFKit4 小时前
使用 PDF API 合并 PDF 文件
前端·javascript·macos
yqcoder4 小时前
react 中 memo 模块作用
前端·javascript·react.js
优雅永不过时·4 小时前
Three.js 原生 实现 react-three-fiber drei 的 磨砂反射的效果
前端·javascript·react.js·webgl·threejs·three
camellias_5 小时前
SpringBoot(二十三)SpringBoot集成JWT
java·spring boot·后端
tebukaopu1486 小时前
springboot如何获取控制层get和Post入参
java·spring boot·后端
昔我往昔6 小时前
SpringBoot 创建对象常见的几种方式
java·spring boot·后端