09-云平台开发
- 本地生成器
- 本地生成器制作工具
- 本地
ftl-meta
制作工具 - 开发在线生成器平台,支持用户在线搜索、使用、制作和分享各类代码生成器,帮助开发者提高定制化开发效率
1. 需求分析
绝大多数复杂的业务逻辑已经完成,接下来要实现项目的线上化。对哪些内容进行线上化?
- 数据线上化
- 元信息线上化,即把元信息配置保存到数据库中
- 项目模板线上化,即把静态文件和模板文件保存到存储服务上
- 代码生成器线上化,即把代码生成器产物包保存到存储服务上
- 功能线上化
- 在线查看生成器的信息
- 在线使用生成器
- 在线使用生成器制作工具
需要开发的功能:
- 用户注册、登录
- 管理员功能:用户管理、生成器管理(增删改查)
- 生成器搜索
- 生成器详情查看
- 生成器创建
- 生成器下载
- 生成器在线使用
- 生成器在线制作
2. 线上化实现流程
大致的思路和流程:
- 先完成库表设计,让DB能支持存储生成器信息
- 一定要结合业务,以自己的业务需求为主
- 多去参考同类业务的库表设计,可以多在 GitHub 搜下
- 实现基本的用户注册、登录、CRUD等功能,让用户能够浏览生成器信息
- 实现文件上传下载功能,让用户能够上传和下载生成器产物包
- 实现在线使用生成器功能,让用户直接在线生成代码
- 实现在线制作生成器功能,提高用户制作生成器效率
- 项目优化,包括性能优化、存储优化等
3. DB设计
1. 用户表
复用后端万用模板的用户表,选用最经典的(账号|密码)登录,移除多余的公众号登录相关的字段 unionId
、mpOpenId
-- 用户表
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. 生成器表
生成器表是整个系统的核心,主要存储(核心字段)以下几部分内容:
- 生成器的元信息,包括基本信息、文件配置(
fileConfig
)、数据模型配置(modelConfig
)等 - 便于吸引用户搜索和使用的信息,包括图片(
picture
)、标签列表(tags
) - 生成器文件信息,主要是生成器产物文件的存储根路径(
distPath
),从而支持用户下载生成器 - 生成器的状态(
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 万用后端模板,在此基础上进行开发
1. 后端项目初始化
1. 项目信息替换
- 替换项目名为
yuzi-generator-web-backend
- 替换包名,将
springbootinit
替换为web
- 修改
application.yml
,项目启动端口号:8120
2. 项目瘦身
移除本项目中用不到的功能和代码:
- 微信公众号、公众号登录相关代码
- Elasticsearch 搜索
- Easy Excel 表格读写
- FreeMarker 模板引擎
- 所有的单元测试代码
- 帖子点赞和收藏功能
- 其他无用的工具类代码
3. DB初始化
执行上述设计得到的 SQL 代码,创建库表
2. 用户功能
项目模板已经提供了现成的用户注册、登录、管理、权限校验功能,不需要额外开发,只要根据库表设计,移除掉 unionId
和 mpOpenId
字段即可
- 注意:使用全局搜索,把所有用到这些字段的代码均删除
3. 生成器功能
1. 代码生成
MyBatisX 插件,实现上述代码的自动生成:
注意:一定要修改 mapper.xml
文件中的实体类和 Mapper 包路径!
2. 数据模型开发
- 修改实体类
Generator
,isDelete
字段补充逻辑删除注解
/**
* 是否删除
*/
@TableLogic
private Integer isDelete;
- 根据实际需求,编写实体类对应的 CRUD 请求、响应包装类
- 在包装类中,不仅控制有哪些属性,还需要将数据表中的 JSON 字符串转换为 Java 对象,便于前端接受和处理。字段有
tags, fileConfig, modelConfig
- 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. 业务逻辑开发
- 直接复制模板中的
PostService, PostController
,通过全局替换名称,快速完成基本的CRUD、搜索、返回包装类等功能- 注意:全局替换时,严格指定大小写,并且不要把
@PostMapping
也替换掉了
- 注意:全局替换时,严格指定大小写,并且不要把
- 替换后,需要根据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;
}
- 移除一些无用代码。eg:把模板中多余的帖子(Post)相关代码删除
4. 测试
运行后端项目,打开 本地接口文档地址,测试用户注册、登录、生成器的CRUD等功能
5. 前端页面开发
提高开发效率,直接使用 ooxx导航的万用前端模板(React + Ant Design 实现)
- 开启格式化
import {requestConfig} from './requestConfig';
import { requestConfig } from './requestConfig';
1. 前端项目初始化
npm install
安装依赖
1. 项目信息修改
- 利用 Ant Design Pro 浏览页面中的主题设置器来切换主题,对导航栏位置、主题色、内容宽度进行修改
- 页面地址
- 编辑好主题后,拷贝设置到项目的
config/defaultSettings.ts
配置中
- 使用全局替换,对标题和描述进行修改
- 标题改为:鱼籽代码生成
- 描述改为:生成器在线制作共享,大幅提升开发效率
- 替换网站图标 AI 画的
- 将准备好的 Logo 文件放到
public
和src/assets
目录下,文件名为logo.png
- 在
app.tsx
的 layout 配置中引入 Logo 文件 - 用在线工具将 PNG 格式的 Logo 转换为 ico 文件,替换 public 目录下的
favicon.ico
- 将准备好的 Logo 文件放到
import logo from '@/assets/logo.png';
export const layout: RunTimeLayoutConfig = ({ initialState }) => {
return {
logo,
// ...
};
};
2. 请求处理
- 修改
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";
- 根据后端接口文档生成前端请求和 TypeScript 类型代码
- 修改
config/config.ts
的openAPI
配置,将schemaPath
改为后端接口文档数据地址
- 修改
注意:建议使用 OpenAPI 2 版本的接口文档,生成的接口更准确。 本集教程中使用的是 OpenAPI 3,后面会做修改
openAPI: [
{
requestLibPath: "import { request } from '@umijs/max'",
schemaPath: 'http://localhost:8120/api/v2/api-docs',
projectName: 'backend',
},
]
// @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. 用户注册页面
- 增加注册页的路由(重启)。不使用默认布局。
layout: false
不展示导航栏
{
path: '/user',
layout: false,
routes: [
{ path: '/user/login', component: './User/Login' },
{ path: '/user/register', component: './User/Register' },
],
}
- 直接复制模板内置的登录页面,然后修改下表单项(增加确认密码)基本就能满足需求
- 需要注意登录页和注册页的相互跳转
- 用户注册页面的完整
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;
3. 管理页面
模板已经内置了用户管理页面,只需要开发生成器管理页面即可
- 先增加页面路由
{
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: '生成器管理',
},
],
}
- 直接复制用户管理页面,然后替换
用户
为生成器
,替换User
为Generator
,替换user
为generator
注意大小写、中文都要替换 - 根据生成器表的字段修改表格列
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>
),
},
];
- 分别测试查询搜索、删除、新建、修改操作,针对有异常的代码进行修复
- 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 || '[]'),
},
}}
- 功能完成后,可以对页面进行优化
- eg:生成器管理页面的表格可以更宽,以展示多列数据
- Ant Design Pro 默认是使用
PageContainer
组件来控制页面整体布局的(包括宽度、标题、面包屑等),只要把该组件替换为div
,就不会受到定宽的约束了 - 增加 4 级标题
<div className="generator-admin-page">
<Typography.Title level={4} style={{ marginBottom: 16 }}>生成器管理</Typography.Title>
...
</div>
生成器管理页面
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 官方预览页的项目搜索列表页
- 修改主页路由
{ path: '/', icon: 'home', component: './Index', name: '主页' }
- 将主页放到
pages/Index
目录下。页面清理干净
import { PageContainer } from '@ant-design/pro-components';
import React from 'react';
/**
* 主页
* @constructor
*/
const IndexPage: React.FC = () => {
return <PageContainer>
</PageContainer>;
};
export default IndexPage;
- 然后请求后端获取生成器列表数据
先获取数据可以便于后续页面调试
/**
* 默认分页参数
*/
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
搜索条件变量,只要搜索条件发生改变(或首次执行),就会立刻出发重新搜索
- 开发基本页面
- 在开发页面时,建议大家多利用现有的组件或代码,而不是从 0 开始编写
- eg:使用 Ant Design Procomponents 的 QueryFilter 组件,快速开发搜索表单
- 还可以直接通过 Ant Design Pro 脚手架(选择
umi@3
)生成官方预览页的现成代码,作为参考
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
- 完善分页和搜索
- 使用 Ant Design 的 List 组件自带的分页功能,当用户切换分页时,会触发 onChange 事件,然后修改 searchParams,就能重新触发搜索
<List<API.GeneratorVO>
pagination={{
current: searchParams.current,
pageSize: searchParams.pageSize,
total,
onChange(current, pageSize) {
setSearchParams({
...searchParams,
current,
pageSize,
});
},
}}
/>
- 最后再调整下页面的细节。eg:元素间距、宽高等,最终页面效果
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;