09-云平台开发

  • 本地生成器
  • 本地生成器制作工具
  • 本地 ftl-meta 制作工具
  • 开发在线生成器平台,支持用户在线搜索、使用、制作和分享各类代码生成器,帮助开发者提高定制化开发效率

1. 需求分析

绝大多数复杂的业务逻辑已经完成,接下来要实现项目的线上化。对哪些内容进行线上化?

  1. 数据线上化
    • 元信息线上化,即把元信息配置保存到数据库中
    • 项目模板线上化,即把静态文件和模板文件保存到存储服务上
    • 代码生成器线上化,即把代码生成器产物包保存到存储服务上
  2. 功能线上化
    • 在线查看生成器的信息
    • 在线使用生成器
    • 在线使用生成器制作工具

需要开发的功能:

  • 用户注册、登录
  • 管理员功能:用户管理、生成器管理(增删改查)
  • 生成器搜索
  • 生成器详情查看
  • 生成器创建
  • 生成器下载
  • 生成器在线使用
  • 生成器在线制作

2. 线上化实现流程

大致的思路和流程:

  1. 先完成库表设计,让DB能支持存储生成器信息
    1. 一定要结合业务,以自己的业务需求为主
    2. 多去参考同类业务的库表设计,可以多在 GitHub 搜下
  2. 实现基本的用户注册、登录、CRUD等功能,让用户能够浏览生成器信息
  3. 实现文件上传下载功能,让用户能够上传和下载生成器产物包
  4. 实现在线使用生成器功能,让用户直接在线生成代码
  5. 实现在线制作生成器功能,提高用户制作生成器效率
  6. 项目优化,包括性能优化、存储优化等

3. DB设计

1. 用户表

复用后端万用模板的用户表,选用最经典的(账号|密码)登录,移除多余的公众号登录相关的字段 unionIdmpOpenId

-- 用户表
create table user (
    id           bigint auto_increment comment 'id' primary key,
    userAccount  varchar(256)                           not null comment '账号',
    userPassword varchar(512)                           not null comment '密码',
    userName     varchar(256)                           null comment '用户昵称',
    userAvatar   varchar(1024)                          null comment '用户头像',
    userProfile  varchar(512)                           null comment '用户简介',
    userRole     varchar(256) default 'user'            not null comment '用户角色:user/admin/ban',
    createTime   datetime     default CURRENT_TIMESTAMP not null comment '创建时间',
    updateTime   datetime     default CURRENT_TIMESTAMP not null on update CURRENT_TIMESTAMP comment '更新时间',
    isDelete     tinyint      default 0                 not null comment '是否删除'
)
    comment '用户表';

create index idx_userAccount
    on user (userAccount);

2. 生成器表

生成器表是整个系统的核心,主要存储(核心字段)以下几部分内容:

  1. 生成器的元信息,包括基本信息、文件配置(fileConfig)、数据模型配置(modelConfig)等
  2. 便于吸引用户搜索和使用的信息,包括图片(picture)、标签列表(tags
  3. 生成器文件信息,主要是生成器产物文件的存储根路径(distPath),从而支持用户下载生成器
  4. 生成器的状态(status),用于表示生成器的制作状态、是否可用等。默认是 0,后面会持续补充更多状态
-- 代码生成器表
create table generator (
    id          bigint auto_increment comment 'id' primary key,
    name        varchar(128)                       null comment '名称',
    description text                               null comment '描述',
    basePackage varchar(128)                       null comment '基础包',
    version     varchar(128)                       null comment '版本',
    author      varchar(128)                       null comment '作者',
    tags        varchar(1024)                      null comment '标签列表(json 数组)',
    picture     varchar(256)                       null comment '图片',
    fileConfig  text                               null comment '文件配置(json字符串)',
    modelConfig text                               null comment '模型配置(json字符串)',
    distPath    text                               null comment '代码生成器产物路径',
    status      int      default 0                 not null comment '状态',
    userId      bigint                             not null comment '创建用户 id',
    createTime  datetime default CURRENT_TIMESTAMP not null comment '创建时间',
    updateTime  datetime default CURRENT_TIMESTAMP not null on update CURRENT_TIMESTAMP comment '更新时间',
    isDelete    tinyint  default 0                 not null comment '是否删除'
)
    comment '代码生成器表';

create index idx_userId
    on generator (userId);










 
 











字段用 json 字符串:字段多、嵌套层级比较杂、结构比较复杂、不确定是否增加字段、数据量不能太大

3. 模拟数据

INSERT INTO proj_db.user (id, userAccount, userPassword, userName, userAvatar, userProfile, userRole)
VALUES (1, 'yupi', 'b0dd3697a192885d7c055db46155b26a', '程序员谷牛',
        'https://gw.alipayobjects.com/zos/rmsportal/BiazfanxmamNRoxxVxka.png', '我有一头小毛驴我从来也不骑', 'admin');

INSERT INTO proj_db.user (id, userAccount, userPassword, userName, userAvatar, userProfile, userRole)
VALUES (2, 'yupi2', 'b0dd3697a192885d7c055db46155b26a', '普通谷牛',
        'https://gw.alipayobjects.com/zos/rmsportal/BiazfanxmamNRoxxVxka.png', '我有一头小毛驴我从来也不骑', 'user');
INSERT INTO proj_db.generator (id, name, description, basePackage, version, author, tags, picture, fileConfig,
                             modelConfig, distPath, status, userId)
VALUES (1, 'ACM 模板项目', 'ACM 模板项目生成器', 'com.yupi', '1.0', '程序员谷牛', '["Java"]',
        'https://pic.yupi.icu/1/_r0_c1851-bf115939332e.jpg', '{}', '{}', null, 0, 1);

INSERT INTO proj_db.generator (id, name, description, basePackage, version, author, tags, picture, fileConfig,
                             modelConfig, distPath, status, userId)
VALUES (2, 'Spring Boot 初始化模板', 'Spring Boot 初始化模板项目生成器', 'com.yupi', '1.0', '程序员谷牛', '["Java"]',
        'https://pic.yupi.icu/1/_r0_c0726-7e30f8db802a.jpg', '{}', '{}', null, 0, 1);

INSERT INTO proj_db.generator (id, name, description, basePackage, version, author, tags, picture, fileConfig,
                             modelConfig, distPath, status, userId)
VALUES (3, '谷牛外卖', '谷牛外卖项目生成器', 'com.yupi', '1.0', '程序员谷牛', '["Java", "前端"]',
        'https://pic.yupi.icu/1/_r1_c0cf7-f8e4bd865b4b.jpg', '{}', '{}', null, 0, 1);

INSERT INTO proj_db.generator (id, name, description, basePackage, version, author, tags, picture, fileConfig,
                             modelConfig, distPath, status, userId)
VALUES (4, '谷牛用户中心', '谷牛用户中心项目生成器', 'com.yupi', '1.0', '程序员谷牛', '["Java", "前端"]',
        'https://pic.yupi.icu/1/_r1_c1c15-79cdecf24aed.jpg', '{}', '{}', null, 0, 1);

INSERT INTO proj_db.generator (id, name, description, basePackage, version, author, tags, picture, fileConfig,
                             modelConfig, distPath, status, userId)
VALUES (5, '谷牛商城', '谷牛商城项目生成器', 'com.yupi', '1.0', '程序员谷牛', '["Java", "前端"]',
        'https://pic.yupi.icu/1/_r1_c0709-8e80689ac1da.jpg', '{}', '{}', null, 0, 1);

4. 后端开发

直接使用 ooxx导航的 Spring Boot 万用后端模板open in new window,在此基础上进行开发

1. 后端项目初始化

1. 项目信息替换

  1. 替换项目名为 yuzi-generator-web-backend
768fe4b630bb41debecb62e5b4097515
  1. 替换包名,将 springbootinit 替换为 web
b806e9e3e8074e60816db7d2cd5da6b6
ad2fefa1e74a44228be52415916526c9
  1. 修改 application.yml,项目启动端口号:8120
00d73df052724836bc664ccaf6bcb145

2. 项目瘦身

移除本项目中用不到的功能和代码:

  • 微信公众号、公众号登录相关代码
  • Elasticsearch 搜索
  • Easy Excel 表格读写
  • FreeMarker 模板引擎
  • 所有的单元测试代码
  • 帖子点赞和收藏功能
  • 其他无用的工具类代码
4165fa01af02460c989d62139be42b47

3. DB初始化

执行上述设计得到的 SQL 代码,创建库表

e22ab74a517e46ff8f595e0fd09da305

2. 用户功能

项目模板已经提供了现成的用户注册、登录、管理、权限校验功能,不需要额外开发,只要根据库表设计,移除掉 unionIdmpOpenId 字段即可

  • 注意:使用全局搜索,把所有用到这些字段的代码均删除

3. 生成器功能

1. 代码生成

MyBatisX 插件,实现上述代码的自动生成:

83195ff2aec24ddb85288ff63720d702
69dc5e4f94964bbc9381710e84e977a7
abf6a6d3efb844149deadcb2555f6a74

注意:一定要修改 mapper.xml 文件中的实体类和 Mapper 包路径!

127906615aa24c5e9e23f1106932d03a

2. 数据模型开发

  1. 修改实体类 GeneratorisDelete 字段补充逻辑删除注解
/**
 * 是否删除
 */
@TableLogic
private Integer isDelete;



 

  1. 根据实际需求,编写实体类对应的 CRUD 请求、响应包装类
fb9475990908482c8c74812ec0463a76
  • 在包装类中,不仅控制有哪些属性,还需要将数据表中的 JSON 字符串转换为 Java 对象,便于前端接受和处理。字段有 tags, fileConfig, modelConfig
c72f78054030449aa2aad15fd07ed92f
  • eg:包装类 GeneratorVO,封装了生成器和创建用户的信息,并提供了 VO 对象和实体类相互转换方法

想要更高效的实现对象转换、属性复制,还可以考虑 MapStruct 等库

@Data
public class GeneratorVO implements Serializable {

    /**
     * id
     */
    private Long id;

    /**
     * 名称
     */
    private String name;

    /**
     * 描述
     */
    private String description;

    /**
     * 基础包
     */
    private String basePackage;

    /**
     * 版本
     */
    private String version;

    /**
     * 作者
     */
    private String author;

    /**
     * 图片
     */
    private String picture;

    /**
     * 标签列表
     */
    private List<String> tags;

    /**
     * 文件配置(json字符串)
     */
    private Meta.FileConfig fileConfig;

    /**
     * 模型配置(json字符串)
     */
    private Meta.ModelConfig modelConfig;

    /**
     * 代码生成器产物路径
     */
    private String distPath;

    /**
     * 状态
     */
    private Integer status;

    /**
     * 创建用户 id
     */
    private Long userId;

    /**
     * 创建时间
     */
    private Date createTime;

    /**
     * 更新时间
     */
    private Date updateTime;

    /**
     * 用户
     */
    private UserVO user;

    private static final long serialVersionUID = 1L;

    /**
     * 包装类转对象
     *
     * @param generatorVO
     * @return
     */
    public static Generator voToObj(GeneratorVO generatorVO) {
        if (generatorVO == null) {
            return null;
        }
        Generator generator = new Generator();
        BeanUtils.copyProperties(generatorVO, generator);
        List<String> tagList = generatorVO.getTags();
        generator.setTags(JSONUtil.toJsonStr(tagList));
        Meta.ModelConfig modelConfig = generatorVO.getModelConfig();
        generator.setModelConfig(JSONUtil.toJsonStr(modelConfig));
        Meta.FileConfig fileConfig = generatorVO.getFileConfig();
        generator.setFileConfig(JSONUtil.toJsonStr(fileConfig));
        return generator;
    }

    /**
     * 对象转包装类
     *
     * @param generator
     * @return
     */
    public static GeneratorVO objToVo(Generator generator) {
        if (generator == null) {
            return null;
        }
        GeneratorVO generatorVO = new GeneratorVO();
        BeanUtils.copyProperties(generator, generatorVO);
        generatorVO.setTags(JSONUtil.toList(generator.getTags(), String.class));
        generatorVO.setFileConfig(JSONUtil.toBean(generator.getFileConfig(), Meta.FileConfig.class));
        generatorVO.setModelConfig(JSONUtil.toBean(generator.getModelConfig(), Meta.ModelConfig.class));
        return generatorVO;
    }
}


































































































 

 

 















 
 
 



3. 业务逻辑开发

  1. 直接复制模板中的 PostService, PostController,通过全局替换名称,快速完成基本的CRUD、搜索、返回包装类等功能
    • 注意:全局替换时,严格指定大小写,并且不要把 @PostMapping 也替换掉了
4a8513072ca44e1da1b274d9540f2a2f
  1. 替换后,需要根据DB字段调整数据校验、查询的逻辑
@Override
public void validGenerator(Generator generator, boolean add) {
    if (generator == null) {
        throw new BusinessException(ErrorCode.PARAMS_ERROR);
    }
    String name = generator.getName();
    String description = generator.getDescription();

    // 创建时,参数不能为空
    if (add) {
        ThrowUtils.throwIf(StringUtils.isAnyBlank(name, description), ErrorCode.PARAMS_ERROR);
    }
    // 有参数则校验
    if (StringUtils.isNotBlank(name) && name.length() > 80) {
        throw new BusinessException(ErrorCode.PARAMS_ERROR, "名称过长");
    }
    if (StringUtils.isNotBlank(description) && description.length() > 256) {
        throw new BusinessException(ErrorCode.PARAMS_ERROR, "描述过长");
    }
}
  • 数据查询代码如下。作用:将前端请求包装类转换为 MyBatis Plus 接受的 QueryWrapper 条件查询对象
@Override
public QueryWrapper<Generator> getQueryWrapper(GeneratorQueryRequest generatorQueryRequest) {
    QueryWrapper<Generator> queryWrapper = new QueryWrapper<>();
    if (generatorQueryRequest == null) {
        return queryWrapper;
    }
    String searchText = generatorQueryRequest.getSearchText();
    String sortField = generatorQueryRequest.getSortField();
    String sortOrder = generatorQueryRequest.getSortOrder();
    Long id = generatorQueryRequest.getId();

    String name = generatorQueryRequest.getName();
    String description = generatorQueryRequest.getDescription();
    List<String> tagList = generatorQueryRequest.getTags();
    Integer status = generatorQueryRequest.getStatus();

    Long userId = generatorQueryRequest.getUserId();
    Long notId = generatorQueryRequest.getNotId();
    // 拼接查询条件
    if (StringUtils.isNotBlank(searchText)) {
        queryWrapper.like("name", searchText).or().like("description", searchText);
    }
    queryWrapper.like(StringUtils.isNotBlank(name), "name", name);
    queryWrapper.like(StringUtils.isNotBlank(description), "description", description);
    if (CollUtil.isNotEmpty(tagList)) {
        for (String tag : tagList) {
            queryWrapper.like("tags", "\"" + tag + "\"");
        }
    }
    queryWrapper.eq(ObjectUtils.isNotEmpty(status), "status", status);
    queryWrapper.ne(ObjectUtils.isNotEmpty(notId), "id", notId);
    queryWrapper.eq(ObjectUtils.isNotEmpty(id), "id", id);
    queryWrapper.eq(ObjectUtils.isNotEmpty(userId), "userId", userId);
    queryWrapper.orderBy(SqlUtils.validSortField(sortField), sortOrder.equals(CommonConstant.SORT_ORDER_ASC),
            sortField);
    return queryWrapper;
}
  1. 移除一些无用代码。eg:把模板中多余的帖子(Post)相关代码删除
RXdmSElkZnF5VE42MUk5dHJPWWViTEhXN200RVQrbXJDbHBQYzZ3PQ==

4. 测试

运行后端项目,打开 本地接口文档地址open in new window,测试用户注册、登录、生成器的CRUD等功能

c09c74292f9849c3bd6d92d1e59cdabd

5. 前端页面开发

提高开发效率,直接使用 ooxx导航的万用前端模板open in new window(React + Ant Design 实现)

  • 开启格式化
a1diN3BLWXhuZXZ0YWN0YWczVlAxbW1nWmJSbjdQNW1qb1FBdUdVPQ==
import {requestConfig} from './requestConfig';
import { requestConfig } from './requestConfig';

1. 前端项目初始化

npm install 安装依赖

1. 项目信息修改

  1. 利用 Ant Design Pro 浏览页面中的主题设置器来切换主题,对导航栏位置、主题色、内容宽度进行修改
f95ad0a8af4f4733a9914607d1fae4b9
6eb82f52730545d0aa92844d5495b192
  1. 使用全局替换,对标题和描述进行修改
    • 标题改为:鱼籽代码生成
    • 描述改为:生成器在线制作共享,大幅提升开发效率
  2. 替换网站图标 AI 画的open in new window
    • 将准备好的 Logo 文件放到 publicsrc/assets 目录下,文件名为 logo.png
    • app.tsx 的 layout 配置中引入 Logo 文件
    • 用在线工具将 PNG 格式的 Logo 转换为 ico 文件,替换 public 目录下的 favicon.ico
65362d5c246a428ebda75ab8964d9d32
import logo from '@/assets/logo.png';

export const layout: RunTimeLayoutConfig = ({ initialState }) => {
  return {
    logo,
    // ...
  };
};

2. 请求处理

  1. 修改 constant/index.ts 中的网站后端地址,本地改为和后端一致
/**
 * 本地后端地址
 */
export const BACKEND_HOST_LOCAL = "http://localhost:8120";

/**
 * 线上后端地址
 */
export const BACKEND_HOST_PROD = "https://yupi.icu";

/**
 * COS 访问地址
 */
export const COS_HOST = "https://yuzi-1256524210.cos.ap-shanghai.myqcloud.com";



 










  1. 根据后端接口文档生成前端请求和 TypeScript 类型代码
    • 修改 config/config.tsopenAPI 配置,将 schemaPath 改为后端接口文档数据地址

注意:建议使用 OpenAPI 2 版本的接口文档,生成的接口更准确。 本集教程中使用的是 OpenAPI 3,后面会做修改

N0FHUWlEWXczeDNMVUdGRjlsQlRIckhXN200RVQrbXJDbHBQYzZ3Q2p3PT0=
openAPI: [
  {
    requestLibPath: "import { request } from '@umijs/max'",
    schemaPath: 'http://localhost:8120/api/v2/api-docs',
    projectName: 'backend',
  },
]



 



TzVjZWpXaXl2MnRpVXZheC81MXovbW1nWmJSbjdQNW1qb1FBdUdVPQ==
// @ts-ignore
/* eslint-disable */
import { request } from '@umijs/max';

/** healthCheck GET /api/health */
export async function healthCheckUsingGet(options?: { [key: string]: any }) {
  return request<string>('/api/health', {
    method: 'GET',
    ...(options || {}),
  });
}
// @ts-ignore
/* eslint-disable */
// API 更新时间:
// API 唯一标识:
import * as fileController from './fileController';
import * as generatorController from './generatorController';
import * as healthController from './healthController';
import * as userController from './userController';
export default {
  fileController,
  generatorController,
  healthController,
  userController,
};











 


2. 用户注册页面

  1. 增加注册页的路由(重启)。不使用默认布局。layout: false 不展示导航栏
{
  path: '/user',
  layout: false,
  routes: [
    { path: '/user/login', component: './User/Login' },
    { path: '/user/register', component: './User/Register' },
  ],
}


 


 


  1. 直接复制模板内置的登录页面,然后修改下表单项(增加确认密码)基本就能满足需求
    • 需要注意登录页和注册页的相互跳转
    • 用户注册页面的完整
import Footer from '@/components/Footer';
import { userRegister } from '@/services/backend/userController';
import { LockOutlined, UserOutlined } from '@ant-design/icons';
import { LoginForm, ProFormText } from '@ant-design/pro-components';
import { useEmotionCss } from '@ant-design/use-emotion-css';
import { Helmet, history } from '@umijs/max';
import { message, Tabs } from 'antd';
import React, { useState } from 'react';
import { Link } from 'umi';
import Settings from '../../../../config/defaultSettings';

/**
 * 用户注册页面
 * @constructor
 */
const UserRegisterPage: React.FC = () => {
  const [type, setType] = useState<string>('account');
  const containerClassName = useEmotionCss(() => {
    return {
      display: 'flex',
      flexDirection: 'column',
      height: '100vh',
      overflow: 'auto',
      backgroundImage:
        "url('https://mdn.alipayobjects.com/yuyan_qk0oxh/afts/img/V-_oS6r-i7wAAAAAAAAAAAAAFl94AQBr')",
      backgroundSize: '100% 100%',
    };
  });

  const handleSubmit = async (values: API.UserRegisterRequest) => {
    try {
      // 注册
      await userRegister({
        ...values,
      });

      const defaultLoginSuccessMessage = '注册成功!';
      message.success(defaultLoginSuccessMessage);
      history.push('/user/login');
      return;
    } catch (error: any) {
      const defaultLoginFailureMessage = `注册失败,${error.message}`;
      message.error(defaultLoginFailureMessage);
    }
  };

  return (
    <div className={containerClassName}>
      <Helmet>
        <title>
          {'注册'}- {Settings.title}
        </title>
      </Helmet>
      <div
        style={{
          flex: '1',
          padding: '32px 0',
        }}
      >
        <LoginForm<API.UserRegisterRequest>
          contentStyle={{
            minWidth: 280,
            maxWidth: '75vw',
          }}
          logo={<img alt="logo" style={{ height: '100%' }} src="/logo.png" />}
          title="鱼籽代码生成"
          subTitle={'代码生成器在线制作共享,大幅提升开发效率'}
          initialValues={{
            autoLogin: true,
          }}
          submitter={{
            searchConfig: {
              submitText: '注册',
            },
          }}
          onFinish={async (values) => {
            await handleSubmit(values);
          }}
        >
          <Tabs
            activeKey={type}
            onChange={setType}
            centered
            items={[
              {
                key: 'account',
                label: '新用户注册',
              },
            ]}
          />
          {type === 'account' && (
            <>
              <ProFormText
                name="userAccount"
                fieldProps={{
                  size: 'large',
                  prefix: <UserOutlined />,
                }}
                placeholder={'请输入账号'}
                rules={[
                  {
                    required: true,
                    message: '账号是必填项!',
                  },
                ]}
              />
              <ProFormText.Password
                name="userPassword"
                fieldProps={{
                  size: 'large',
                  prefix: <LockOutlined />,
                }}
                placeholder={'请输入密码'}
                rules={[
                  {
                    required: true,
                    message: '密码是必填项!',
                  },
                ]}
              />
              <ProFormText.Password
                name="checkPassword"
                fieldProps={{
                  size: 'large',
                  prefix: <LockOutlined />,
                }}
                placeholder={'请再次确认密码'}
                rules={[
                  {
                    required: true,
                    message: '确认密码是必填项!',
                  },
                ]}
              />
            </>
          )}

          <div
            style={{
              marginBottom: 24,
              textAlign: 'right',
            }}
          >
            <Link to="/user/login">老用户登录</Link>
          </div>
        </LoginForm>
      </div>
      <Footer />
    </div>
  );
};
export default UserRegisterPage;















 













 


 





 








































































































 








c3127c9ff6f84ae09d3cf2731d3fd87f

3. 管理页面

模板已经内置了用户管理页面,只需要开发生成器管理页面即可

  1. 先增加页面路由
{
  path: '/admin',
  icon: 'crown',
  name: '管理页',
  access: 'canAdmin',
  routes: [
    { path: '/admin', redirect: '/admin/user' },
    { icon: 'user', path: '/admin/user', component: './Admin/User', name: '用户管理' },
    {
      icon: 'tools',
      path: '/admin/generator',
      component: './Admin/Generator',
      name: '生成器管理',
    },
  ],
}








 
 
 
 
 
 


  1. 直接复制用户管理页面,然后替换 用户生成器,替换 UserGenerator,替换 usergenerator 注意大小写、中文都要替换
  2. 根据生成器表的字段修改表格列 columns 配置,需要尤其注意标签的渲染(复制接口响应 json,进行修改)
    • 标签列的配置(新建、查询)
  /**
   * 表格列配置
   */
  const columns: ProColumns<API.Generator>[] = [
    {
      title: 'id',
      dataIndex: 'id',
      valueType: 'text',
      hideInForm: true,
    },
    {
      title: '名称',
      dataIndex: 'name',
      valueType: 'text',
    },
    {
      title: '描述',
      dataIndex: 'description',
      valueType: 'textarea',
    },
    {
      title: '基础包',
      dataIndex: 'basePackage',
      valueType: 'text',
    },
    {
      title: '版本',
      dataIndex: 'version',
      valueType: 'text',
    },
    {
      title: '作者',
      dataIndex: 'author',
      valueType: 'text',
    },
    {
      title: '标签',
      dataIndex: 'tags',
      valueType: 'text',
      renderFormItem(schema) {
        const { fieldProps } = schema;
        // @ts-ignore
        return <Select mode="tags" {...fieldProps} />;
      },
      render(_, record) {
        if (!record.tags) {
          return <></>;
        }

        return JSON.parse(record.tags).map((tag: string) => {
          return <Tag key={tag}>{tag}</Tag>;
        });
      },
    },
    {
      title: '图片',
      dataIndex: 'picture',
      valueType: 'image',
      fieldProps: {
        width: 64,
      },
      hideInSearch: true,
    },
    {
      title: '文件配置',
      dataIndex: 'fileConfig',
      valueType: 'jsonCode',
    },
    {
      title: '模型配置',
      dataIndex: 'modelConfig',
      valueType: 'jsonCode',
    },
    {
      title: '产物包路径',
      dataIndex: 'distPath',
      valueType: 'text',
    },
    {
      title: '状态',
      dataIndex: 'status',
      valueEnum: {
        0: {
          text: '默认',
        },
      },
    },
    {
      title: '创建用户',
      dataIndex: 'userId',
      valueType: 'text',
    },
    {
      title: '创建时间',
      sorter: true,
      dataIndex: 'createTime',
      valueType: 'dateTime',
      hideInSearch: true,
      hideInForm: true,
    },
    {
      title: '更新时间',
      sorter: true,
      dataIndex: 'updateTime',
      valueType: 'dateTime',
      hideInSearch: true,
      hideInForm: true,
    },
    {
      title: '操作',
      dataIndex: 'option',
      valueType: 'option',
      render: (_, record) => (
        <Space size="middle">
          <Typography.Link
            onClick={() => {
              setCurrentRow(record);
              setUpdateModalVisible(true);
            }}
          >
            修改
          </Typography.Link>
          <Typography.Link type="danger" onClick={() => handleDelete(record)}>
            删除
          </Typography.Link>
        </Space>
      ),
    },
  ];







































 
 
 
 
 
 
 
 
 
 
 
 
 
 












































































  1. 分别测试查询搜索、删除、新建、修改操作,针对有异常的代码进行修复
    • eg:创建函数中,需要将 JSON 字符串转换为 JS 对象
/**
 * 添加节点
 * @param fields
 */
const handleAdd = async (fields: API.GeneratorAddRequest) => {
  fields.fileConfig = JSON.parse((fields.fileConfig || '{}') as string);
  fields.modelConfig = JSON.parse((fields.modelConfig || '{}') as string);
  const hide = message.loading('正在添加');
  try {
    await addGenerator(fields);
    hide();
    message.success('创建成功');
    return true;
  } catch (error: any) {
    hide();
    message.error('创建失败,' + error.message);
    return false;
  }
};





 
 












  • 修改操作中,需要注意给 tags 添加默认值
form={{
  initialValues: {
    ...oldData,
    tags: JSON.parse(oldData.tags || '[]'),
  },
}}



 


  1. 功能完成后,可以对页面进行优化
    • eg:生成器管理页面的表格可以更宽,以展示多列数据
    • Ant Design Pro 默认是使用 PageContainer 组件来控制页面整体布局的(包括宽度、标题、面包屑等),只要把该组件替换为 div,就不会受到定宽的约束了
    • 增加 4 级标题
<div className="generator-admin-page">
  <Typography.Title level={4} style={{ marginBottom: 16 }}>生成器管理</Typography.Title>
  ...
</div>
b6012916e0e44df2bb6c04de15d59bc4

生成器管理页面

import CreateModal from '@/pages/Admin/Generator/components/CreateModal';
import UpdateModal from '@/pages/Admin/Generator/components/UpdateModal';
import { deleteGenerator, listGeneratorByPage } from '@/services/backend/generatorController';
import { PlusOutlined } from '@ant-design/icons';
import type { ActionType, ProColumns } from '@ant-design/pro-components';
import { ProTable } from '@ant-design/pro-components';
import '@umijs/max';
import { Button, message, Select, Space, Tag, Typography } from 'antd';
import React, { useRef, useState } from 'react';

/**
 * 生成器管理页面
 *
 * @constructor
 */
const GeneratorAdminPage: React.FC = () => {
  // 是否显示新建窗口
  const [createModalVisible, setCreateModalVisible] = useState<boolean>(false);
  // 是否显示更新窗口
  const [updateModalVisible, setUpdateModalVisible] = useState<boolean>(false);
  const actionRef = useRef<ActionType>();
  // 当前用户点击的数据
  const [currentRow, setCurrentRow] = useState<API.Generator>();

  /**
   * 删除节点
   *
   * @param row
   */
  const handleDelete = async (row: API.Generator) => {
    const hide = message.loading('正在删除');
    if (!row) return true;
    try {
      await deleteGenerator({
        id: row.id as any,
      });
      hide();
      message.success('删除成功');
      actionRef?.current?.reload();
      return true;
    } catch (error: any) {
      hide();
      message.error('删除失败,' + error.message);
      return false;
    }
  };

  /**
   * 表格列配置
   */
  const columns: ProColumns<API.Generator>[] = [
    {
      title: 'id',
      dataIndex: 'id',
      valueType: 'text',
      hideInForm: true,
    },
    {
      title: '名称',
      dataIndex: 'name',
      valueType: 'text',
    },
    {
      title: '描述',
      dataIndex: 'description',
      valueType: 'textarea',
    },
    {
      title: '基础包',
      dataIndex: 'basePackage',
      valueType: 'text',
      hideInSearch: true,
    },
    {
      title: '版本',
      dataIndex: 'version',
      valueType: 'text',
      hideInSearch: true,
    },
    {
      title: '作者',
      dataIndex: 'author',
      valueType: 'text',
    },
    {
      title: '标签',
      dataIndex: 'tags',
      valueType: 'text',
      renderFormItem: (schema) => {
        const { fieldProps } = schema;
        // @ts-ignore
        return <Select {...fieldProps} mode="tags" />;
      },
      render(_, record) {
        if (!record.tags) {
          return <></>;
        }
        return JSON.parse(record.tags).map((tag: string) => {
          return <Tag key={tag}>{tag}</Tag>;
        });
      },
    },
    {
      title: '文件配置',
      dataIndex: 'fileConfig',
      valueType: 'jsonCode',
      hideInSearch: true,
    },
    {
      title: '模型配置',
      dataIndex: 'modelConfig',
      valueType: 'jsonCode',
      hideInSearch: true,
    },
    {
      title: '产物路径',
      dataIndex: 'distPath',
      valueType: 'text',
      hideInSearch: true,
    },
    {
      title: '图片',
      dataIndex: 'picture',
      valueType: 'image',
      fieldProps: {
        width: 64,
      },
      hideInSearch: true,
    },
    {
      title: '状态',
      dataIndex: 'status',
      valueEnum: {
        0: {
          text: '默认',
        },
      },
    },
    {
      title: '创建用户',
      dataIndex: 'userId',
      valueType: 'text',
      hideInForm: true,
    },
    {
      title: '创建时间',
      sorter: true,
      dataIndex: 'createTime',
      valueType: 'dateTime',
      hideInSearch: true,
      hideInForm: true,
    },
    {
      title: '更新时间',
      sorter: true,
      dataIndex: 'updateTime',
      valueType: 'dateTime',
      hideInSearch: true,
      hideInForm: true,
    },
    {
      title: '操作',
      dataIndex: 'option',
      valueType: 'option',
      render: (_, record) => (
        <Space size="middle">
          <Typography.Link
            onClick={() => {
              setCurrentRow(record);
              setUpdateModalVisible(true);
            }}
          >
            修改
          </Typography.Link>
          <Typography.Link type="danger" onClick={() => handleDelete(record)}>
            删除
          </Typography.Link>
        </Space>
      ),
    },
  ];
  return (
    <div className="generator-admin-page">
      <Typography.Title level={4} style={{ marginBottom: 16 }}>
        生成器管理
      </Typography.Title>
      <ProTable<API.Generator>
        headerTitle={'查询表格'}
        actionRef={actionRef}
        rowKey="key"
        search={{
          labelWidth: 120,
        }}
        toolBarRender={() => [
          <Button
            type="primary"
            key="primary"
            onClick={() => {
              setCreateModalVisible(true);
            }}
          >
            <PlusOutlined /> 新建
          </Button>,
        ]}
        request={async (params, sort, filter) => {
          const sortField = Object.keys(sort)?.[0];
          const sortOrder = sort?.[sortField] ?? undefined;

          const { data, code } = await listGeneratorByPage({
            ...params,
            sortField,
            sortOrder,
            ...filter,
          } as API.GeneratorQueryRequest);

          return {
            success: code === 0,
            data: data?.records || [],
            total: Number(data?.total) || 0,
          };
        }}
        columns={columns}
      />
      <CreateModal
        visible={createModalVisible}
        columns={columns}
        onSubmit={() => {
          setCreateModalVisible(false);
          actionRef.current?.reload();
        }}
        onCancel={() => {
          setCreateModalVisible(false);
        }}
      />
      <UpdateModal
        visible={updateModalVisible}
        columns={columns}
        oldData={currentRow}
        onSubmit={() => {
          setUpdateModalVisible(false);
          setCurrentRow(undefined);
          actionRef.current?.reload();
        }}
        onCancel={() => {
          setUpdateModalVisible(false);
        }}
      />
    </div>
  );
};
export default GeneratorAdminPage;

4. 主页(搜索列表页)

推荐仿照 Ant Design Pro 官方预览页的项目搜索列表页open in new window

  1. 修改主页路由
{ path: '/', icon: 'home', component: './Index', name: '主页' }
  1. 将主页放到 pages/Index 目录下。页面清理干净
import { PageContainer } from '@ant-design/pro-components';
import React from 'react';

/**
 * 主页
 * @constructor
 */
const IndexPage: React.FC = () => {
  return <PageContainer>

  </PageContainer>;
};

export default IndexPage;
  1. 然后请求后端获取生成器列表数据

先获取数据可以便于后续页面调试

/**
 * 默认分页参数
 */
const DEFAULT_PAGE_PARAMS: PageRequest = {
  current: 1,
  pageSize: 4,
  sortField: 'createTime',
  sortOrder: 'descend',
};

const [loading, setLoading] = useState<boolean>(false);
const [dataList, setDataList] = useState<API.GeneratorVO[]>([]);
const [total, setTotal] = useState<number>(0);
const [searchParams, setSearchParams] = useState<API.GeneratorQueryRequest>({
  ...DEFAULT_PAGE_PARAMS,
});

const doSearch = async () => {
  setLoading(true);
  try {
    const res = await listGeneratorVoByPage(searchParams);
    setDataList(res.data?.records ?? []);
    setTotal(Number(res.data?.total) ?? 0);
  } catch (error: any) {
    message.error('获取数据失败,' + error.message);
  }
  setLoading(false);
};

useEffect(() => {
  doSearch();
}, [searchParams]);

上述代码中,通过 useEffect 钩子监听 searchParams 搜索条件变量,只要搜索条件发生改变(或首次执行),就会立刻出发重新搜索

  1. 开发基本页面
    • 在开发页面时,建议大家多利用现有的组件或代码,而不是从 0 开始编写
    • eg:使用 Ant Design Procomponents 的 QueryFilter 组件open in new window,快速开发搜索表单
dc52b265974f4032b0ee517068323103
  • 还可以直接通过 Ant Design Pro 脚手架(选择 umi@3)生成官方预览页的现成代码,作为参考
a18a0fde69294b7fa86969b631520dab
npm i @ant-design/pro-cli -g# pro create myapp
? 🚀 要全量的还是一个简单的脚手架? complete
> clone repo url: https://gitee.com/ant-design/ant-design-pro
Cloning into 'myapp'...
remote: Enumerating objects: 193, done.
remote: Counting objects: 100% (193/193), done.
remote: Compressing objects: 100% (161/161), done.
remote: Total 193 (delta 36), reused 122 (delta 22), pack-reused 0
Receiving objects: 100% (193/193), 368.55 KiB | 608.00 KiB/s, done.
Resolving deltas: 100% (36/36), done.
> 🚚 clone success
> Clean up...

No change to package.json was detected. No package manager install will be executed.

cd myapp

npm install
 

 














 

 
  1. 完善分页和搜索
    • 使用 Ant Design 的 List 组件自带的分页功能,当用户切换分页时,会触发 onChange 事件,然后修改 searchParams,就能重新触发搜索
<List<API.GeneratorVO>
  pagination={{
    current: searchParams.current,
    pageSize: searchParams.pageSize,
    total,
    onChange(current, pageSize) {
      setSearchParams({
        ...searchParams,
        current,
        pageSize,
      });
    },
	}}
/>
  1. 最后再调整下页面的细节。eg:元素间距、宽高等,最终页面效果
595783b3c975477280a8ab7efe1f25b1
import { listGeneratorVoByPage } from '@/services/backend/generatorController';
import { UserOutlined } from '@ant-design/icons';
import { PageContainer, ProFormSelect, ProFormText, QueryFilter } from '@ant-design/pro-components';
import { Avatar, Card, Flex, Image, Input, List, message, Tabs, Tag, Typography } from 'antd';
import moment from 'moment';
import React, { useEffect, useState } from 'react';

/**
 * 默认分页参数
 */
const DEFAULT_PAGE_PARAMS: PageRequest = {
  current: 1,
  pageSize: 4,
  sortField: 'createTime',
  sortOrder: 'descend',
};

/**
 * 主页
 * @constructor
 */
const IndexPage: React.FC = () => {
  const [loading, setLoading] = useState<boolean>(true);
  const [dataList, setDataList] = useState<API.GeneratorVO[]>([]);
  const [total, setTotal] = useState<number>(0);
  // 搜索条件
  const [searchParams, setSearchParams] = useState<API.GeneratorQueryRequest>({
    ...DEFAULT_PAGE_PARAMS,
  });

  /**
   * 搜索
   */
  const doSearch = async () => {
    setLoading(true);
    try {
      const res = await listGeneratorVoByPage(searchParams);
      setDataList(res.data?.records ?? []);
      setTotal(Number(res.data?.total) ?? 0);
    } catch (error: any) {
      message.error('获取数据失败,' + error.message);
    }
    setLoading(false);
  };

  // 监听数据,执行方法
  useEffect(() => {
    doSearch();
  }, [searchParams]);

  /**
   * 标签列表
   * @param tags
   */
  const tagListView = (tags?: string[]) => {
    if (!tags) {
      return <></>;
    }

    return (
      <div style={{ marginBottom: 8 }}>
        {tags.map((tag) => (
          <Tag key={tag}>{tag}</Tag>
        ))}
      </div>
    );
  };

  return (
    <PageContainer title={<></>}>
      <Flex justify="center">
        <Input.Search
          style={{
            width: '40vw',
            minWidth: 320,
          }}
          placeholder="搜索代码生成器"
          allowClear
          enterButton="搜索"
          size="large"
          onChange={(e) => {
            searchParams.searchText = e.target.value;
          }}
          onSearch={(value: string) => {
            setSearchParams({
              ...DEFAULT_PAGE_PARAMS,
              searchText: value,
            });
          }}
        />
      </Flex>
      <div style={{ marginBottom: 16 }} />

      <Tabs
        size="large"
        defaultActiveKey="newest"
        items={[
          {
            key: 'newest',
            label: '最新',
          },
          {
            key: 'recommend',
            label: '推荐',
          },
        ]}
        onChange={() => {}}
      />

      <QueryFilter
        span={12}
        labelWidth="auto"
        labelAlign="left"
        defaultCollapsed={false}
        style={{ padding: '16px 0' }}
        onFinish={async (values: API.GeneratorQueryRequest) => {
          setSearchParams({
            ...DEFAULT_PAGE_PARAMS,
            // @ts-ignore
            ...values,
            searchText: searchParams.searchText,
          });
        }}
      >
        <ProFormSelect label="标签" name="tags" mode="tags" />
        <ProFormText label="名称" name="name" />
        <ProFormText label="描述" name="description" />
      </QueryFilter>

      <div style={{ marginBottom: 24 }} />

      <List<API.GeneratorVO>
        rowKey="id"
        loading={loading}
        grid={{
          gutter: 16,
          xs: 1,
          sm: 2,
          md: 3,
          lg: 3,
          xl: 4,
          xxl: 4,
        }}
        dataSource={dataList}
        pagination={{
          current: searchParams.current,
          pageSize: searchParams.pageSize,
          total,
          onChange(current: number, pageSize: number) {
            setSearchParams({
              ...searchParams,
              current,
              pageSize,
            });
          },
        }}
        renderItem={(data) => (
          <List.Item>
            <Card hoverable cover={<Image alt={data.name} src={data.picture} />}>
              <Card.Meta
                title={<a>{data.name}</a>}
                description={
                  <Typography.Paragraph ellipsis={{ rows: 2 }} style={{ height: 44 }}>
                    {data.description}
                  </Typography.Paragraph>
                }
              />
              {tagListView(data.tags)}
              <Flex justify="space-between" align="center">
                <Typography.Text type="secondary" style={{ fontSize: 12 }}>
                  {moment(data.createTime).fromNow()}
                </Typography.Text>
                <div>
                  <Avatar src={data.user?.userAvatar ?? <UserOutlined />} />
                </div>
              </Flex>
            </Card>
          </List.Item>
        )}
      />
    </PageContainer>
  );
};

export default IndexPage;