10-生成器共享

0. 前置修改(极其重要)

后端万用模板使用 OpenAPI 3 版本的接口文档,但是前端 OpenAPI 代码生成器无法很好和该版本的接口文档兼容,建议使用 OpenAPI 2 版本的接口文档

1. 后端修改

  1. 更换引入的接口文档依赖:
<!-- https://doc.xiaominfo.com/docs/quick-start#openapi2 -->
<dependency>
    <groupId>com.github.xiaoymin</groupId>
    <artifactId>knife4j-openapi2-spring-boot-starter</artifactId>
    <version>4.4.0</version>
</dependency>
  1. 修改接口文档配置
# 接口文档配置
knife4j:
  enable: true
  openapi:
    title: "接口文档"
    version: 1.0
    group:
      default:
        api-rule: package
        api-rule-resources:
          - com.yupi.web.controller










 
# 接口文档配置
knife4j:
  basic:
    enable: true
    username: root
    password: ******
  1. 访问 http://localhost:8120/api/v2/api-docs 可以看到接口文档定义数据
35e4372165964e56afd72f8da496a3ad

2. 前端修改

  1. 替换 OpenAPI 生成工具指定的接口文档地址,重新生成代码
    • 重新生成后,得到的代码和原来有一些不同,需要做对应的调整
openAPI: [
  {
    requestLibPath: "import { request } from '@umijs/max'",
    schemaPath: 'http://localhost:8120/api/v2/api-docs',
    projectName: 'backend',
  },
]



 



  1. 修改本地请求地址,移除 /api 后缀,因为生成的代码自动将后缀拼接到了请求代码中
/**
 * 本地后端地址
 */
export const BACKEND_HOST_LOCAL = "http://localhost:8120";



 
  1. 启动项目,根据报错修改所有请求函数名称,因为新生成的函数名后缀会携带请求类型
2bf65be4b382470c994ff726d400b6d3
  1. listUserByPage 替换为 listUserByPageUsingPost 即可,其他位置同理
  2. 修改完成后,顺利启动项目

1. 需求分析

核心需求:实现文件上传下载功能,让用户能够上传和下载代码生成器产物包

  1. 代码生成器创建(修改)页面,用户可以上传生成器
  2. 代码生成器详情页面,用户可以查看和下载代码生成器
540b7eecbf414d4cba68aaa8affee06b

2. 通用文件上传下载

最简单的方式:上传到后端项目所在的服务器,直接使用 Java 自带的文件读写 API 就能实现。缺点:

  1. 不利于扩展:单个服务器的存储是有限的,如果存满了,只能再新增存储空间或清理文件
  2. 不利于迁移:如果后端项目要更换服务器部署,之前所有的文件都要迁移到新服务器,非常麻烦
  3. 不够安全:如果忘记控制权限,用户很有可能通过恶意代码访问服务器上的文件,而且想控制权限也比较麻烦,需要自己实现
  4. 不利于管理:只能通过一些文件管理器进行简单的管理操作,但是缺乏数据处理、流量控制等多种高级能力

因此,除了存储一些需要清理的临时文件之外,通常不会将用户上传并保存的文件(eg:用户头像)直接上传到服务器,而是更推荐使用专业的第三方存储服务,专业的工具做专业的事。其中,最常用的便是 对象存储

1. 什么是对象存储

对象存储是一种存储 海量文件分布式 存储服务,具有高扩展性、低成本、可靠安全等优点

  • 比如开源的对象存储服务 MinIO,还有商业版的云服务,像亚马逊 S3(Amazon S3)、阿里云对象存储(OSS)、腾讯云对象存储(COS)等
  • 使用最多的对象存储服务当属腾讯云的 COS 了,除了基本的对象存储的优点外,还可以通过控制台、API、SDK 和工具等多样化方式,简单快速地接入 COS,进行多格式文件的上传、下载和管理,实现海量数据存储和管理
  • 之前搭建的图床就是使用了 COS 对象存储实现

2. 创建并使用

首先进入对象存储的控制台,创建存储桶,腾讯 COS 地址open in new window

  • 可以把存储桶理解为一个存储空间,和文件系统类似,都是根据路径找到文件或目录(eg:/test/aaa.jpg)。可以多个项目共用一个存储桶,也可以每个项目一个
  • 点击创建存储桶,注意地域选择国内(离用户较近的位置)。此处访问权限先选择“公有读私有写”,因为存储桶要存储允许用户公开访问的代码生成器图片
    • 如果整个存储桶要存储的文件都不允许用户访问,建议选择私有读写,更安全
  • 默认告警一定要勾选!因为对象存储服务的存储和访问流量都是计费的,超限后要第一时间得到通知并进行相应的处理
    • 不用太担心,自己做项目一般是没人攻击的,而且对象存储很便宜,正常情况下消耗的费用寥寥无几
09faf01fb31e46479e8c9b928e215eda
d847f72b13ad4daa978cc9ecc7ad0868
c12102fc3cef416db8425e714c9a83ea

开通成功后,可以试着使用 web 控制台上传和浏览文件:

f0e819fe14b94e1e82fca8136b81f74e

上传文件后,可以使用对象存储服务生成的默认域名,在线访问图片

f89c54aaee234af9af6d51d8da3b2564

3. 后端操作对象存储

c22016ed0f21410781cb267253099154

1. 初始化客户端

<!-- https://cloud.tencent.com/document/product/436/10199-->
<dependency>
    <groupId>com.qcloud</groupId>
    <artifactId>cos_api</artifactId>
    <version>5.6.89</version>
</dependency>

参考官方文档,要先初始化一个 COS 客户端对象,和对象存储服务进行交互

e65cdec57732401da32bc29483e9d2d6
  1. config 目录下新建 CosClientConfig 类。负责读取配置文件,并创建一个 COS 客户端的 Bean
@Configuration
@ConfigurationProperties(prefix = "cos.client")
@Data
public class CosClientConfig {

    /**
     * accessKey
     */
    private String accessKey;

    /**
     * secretKey
     */
    private String secretKey;

    /**
     * 区域
     */
    private String region;

    /**
     * 桶名
     */
    private String bucket;

    @Bean
    public COSClient cosClient() {
        // 初始化用户身份信息(secretId, secretKey)
        COSCredentials cred = new BasicCOSCredentials(accessKey, secretKey);
        // 设置 bucket 的区域, COS 地域的简称请参照 https://www.qcloud.com/document/product/436/6224
        ClientConfig clientConfig = new ClientConfig(new Region(region));
        // 生成 cos 客户端
        return new COSClient(cred, clientConfig);
    }
}
  1. 填写配置文件
    • 一定要注意防止密码泄露! 所以新建 application-local.yml 文件,并且在 .gitignore 中忽略该文件的提交,这样就不会将代码等敏感配置提交到代码仓库了
    • accessKeysecretKey 密钥对:在腾讯云访问管理 => 密钥管理中获取open in new window
# 本地配置文件
# 对象存储
cos:
  client:
    accessKey: xxx
    secretKey: xxx
    region: xxx
    bucket: xxx
72894296803b45b8978bb8d3593654152fabf19548d345b994ff52e2595fdb5a

2. 通用能力类 Manager

CosManager 类,提供通用的对象存储操作(eg:文件上传、文件下载等),供其他代码调用

@Component
public class CosManager {

    @Resource
    private CosClientConfig cosClientConfig;

    @Resource
    private COSClient cosClient;

    // ... 一些操作 COS 的方法
}

3. 文件上传

参考官方文档的“上传对象”部分open in new window

  1. CosManager 新增两个上传对象的方法
@Component
public class CosManager {

    @Resource
    private CosClientConfig cosClientConfig;

    @Resource
    private COSClient cosClient;

    /**
     * 上传对象
     *
     * @param key           唯一键
     * @param localFilePath 本地文件路径
     */
    public PutObjectResult putObject(String key, String localFilePath) {
        PutObjectRequest putObjectRequest = new PutObjectRequest(
            cosClientConfig.getBucket(), key, new File(localFilePath));
        return cosClient.putObject(putObjectRequest);
    }

    /**
     * 上传对象
     *
     * @param key  唯一键
     * @param file 文件
     */
    public PutObjectResult putObject(String key, File file) {
        PutObjectRequest putObjectRequest = new PutObjectRequest(
            cosClientConfig.getBucket(), key, file);
        return cosClient.putObject(putObjectRequest);
    }
}
  1. 修改 FileConstant 常量中的 COS 访问域名,便于接下来测试访问已上传的文件
package com.yupi.web.constant;

/**
 * 文件常量
 */
public interface FileConstant {

    /**
     * COS 访问地址
     * todo 需替换配置
     */
    String COS_HOST = "https://yuzi-1256524210.cos.ap-shanghai.myqcloud.com";
}

该域名可以在 COS 控制台的域名信息部分找到:

8dec9bb683e14f3080fd955db3b18232
  1. 为了方便测试,在 FileController 中编写测试文件上传接口
    • 注意:测试接口一定要加上管理员权限!防止任何用户随意上传文件
/**
 * 测试文件上传
 */
@AuthCheck(mustRole = UserConstant.ADMIN_ROLE)
@PostMapping("/test/upload")
public BaseResponse<String> testUploadFile(@RequestPart("file") MultipartFile multipartFile) {
    // 文件目录
    String filename = multipartFile.getOriginalFilename();
    String filepath = String.format("/test/%s", filename);
    File file = null;
    try {
        // 上传文件
        file = File.createTempFile(filepath, null);
        multipartFile.transferTo(file);
        cosManager.putObject(filepath, file);
        // 返回可访问地址
        return ResultUtils.success(filepath);
    } catch (Exception e) {
        log.error("file upload error, filepath = " + filepath, e);
        throw new BusinessException(ErrorCode.SYSTEM_ERROR, "上传失败");
    } finally {
        if (file != null) {
            // 删除临时文件
            boolean delete = file.delete();
            if (!delete) {
                log.error("file delete error, filepath = {}", filepath);
            }
        }
    }
}
  1. 打开 Swagger 接口文档
85ac7943713c42528c74aba5e389eb7d

4. 文件下载

官方文档介绍了 2 种文件下载方式,对象存储>SDK 文档>Java SDK>快速入门open in new window

  1. 直接下载 COS 的文件到后端服务器(适合服务器端处理文件)
  2. 获取到文件下载输入流(适合返回给前端用户)
  3. 直接通过路径链接访问。适用于单一的、可以被用户公开访问的资源(eg:用户头像、本项目中的代码生成器图片)

  1. 首先在 CosManager 中新增对象下载方法,根据对象的 key 获取存储信息:
/**
 * 下载对象
 *
 * @param key 唯一键
 */
public COSObject getObject(String key) {
    GetObjectRequest getObjectRequest = new GetObjectRequest(cosClientConfig.getBucket(), key);
    return cosClient.getObject(getObjectRequest);
}
  1. 为了方便测试,在 FileController 中编写测试文件下载接口
    • 测试接口一定要加上管理员权限!防止任何用户随意上传文件
/**
 * 测试文件下载
 *
 * @param filepath
 * @param response
 */
@AuthCheck(mustRole = UserConstant.ADMIN_ROLE)
@GetMapping("/test/download/")
public void testDownloadFile(String filepath, HttpServletResponse response) throws IOException {
    COSObjectInputStream cosObjectInput = null;
    try {
        COSObject cosObject = cosManager.getObject(filepath);
        cosObjectInput = cosObject.getObjectContent();
        // 处理下载到的流
        byte[] bytes = IOUtils.toByteArray(cosObjectInput);
        // 设置响应头
        response.setContentType("application/octet-stream;charset=UTF-8");
        response.setHeader("Content-Disposition", "attachment; filename=" + filepath);
        // 写入响应
        response.getOutputStream().write(bytes);
        response.getOutputStream().flush();
    } catch (Exception e) {
        log.error("file download error, filepath = " + filepath, e);
        throw new BusinessException(ErrorCode.SYSTEM_ERROR, "下载失败");
    } finally {
        if (cosObjectInput != null) {
            cosObjectInput.close();
        }
    }
}
  1. 启动项目,打开 Swagger 接口文档,测试文件下载:
7e17932505564490b5bcec129f7b2c6f

4. 前端文件上传 / 下载

  1. 使用 OpenAPI 工具生成接口
96ade64970b443ccb6f9786d14731b33
  1. 新建文件上传下载测试页面,并添加路由:
{
  path: '/test/file',
  icon: 'home',
  component: './Test/File',
  name: '文件上传下载测试',
  hideInMenu: true,
},
2f5894e225f546f7874cdf8333cb5395
  1. 新增对象存储相关常量。constants/index.ts
/**
 * COS 访问地址
 */
export const COS_HOST = "https://yuzi-1256524210.cos.ap-shanghai.myqcloud.com";
  1. 开发页面
    • 遵循 Flex 左右布局,左边上传文件,右边展示和下载文件
    • 对于文件上传,直接使用 Ant Design 的拖拽文件上传组件,Upload 上传open in new window
dc5bdffbed9b495abadec882961ea1ef

使用 file-saveropen in new window 库,可以下载后端返回的 blob 内容为文件

npm install file-saver

# 类型校验只在开发时用到
npm i --save-dev @types/file-saver

后端下载文件接口不返回 code 状态码,修改 requestConfig.ts 响应拦截器,对于文件下载请求,直接返回 blob 对象

// 响应拦截器
responseInterceptors: [
  (response) => {
    // 请求地址
    const requestPath: string = response.config.url ?? '';

    // 响应
    const { data } = response as unknown as ResponseStructure;
    if (!data) {
      throw new Error('服务异常');
    }

    // 文件下载时,直接返回
    if (requestPath.includes("download")) {
      return response;
    }

  	...
  },
]












 
 
 
 




import { COS_HOST } from '@/constants';
import {
  testDownloadFileUsingGet,
  testUploadFileUsingPost,
} from '@/services/backend/fileController';
import { InboxOutlined } from '@ant-design/icons';
import { Button, Card, Divider, Flex, message, Upload, UploadProps } from 'antd';
import { saveAs } from 'file-saver';
import React, { useState } from 'react';

const { Dragger } = Upload;

/**
 * 文件上传下载测试页面
 * @constructor
 */
const TestFilePage: React.FC = () => {
  const [value, setValue] = useState<string>();

  const props: UploadProps = {
    name: 'file',
    multiple: false,
    maxCount: 1,
    customRequest: async (fileObj: any) => {
      try {
        const res = await testUploadFileUsingPost({}, fileObj.file);
        fileObj.onSuccess(res.data);
        setValue(res.data);
      } catch (e: any) {
        message.error('上传失败,' + e.message);
        fileObj.onError(e);
      }
    },
    onRemove() {
      setValue(undefined);
    },
  };

  return (
    <Flex gap={16}>
      <Card title="文件上传">
        <Dragger {...props}>
          <p className="ant-upload-drag-icon">
            <InboxOutlined />
          </p>
          <p className="ant-upload-text">Click or drag file to this area to upload</p>
          <p className="ant-upload-hint">
            Support for a single or bulk upload. Strictly prohibited from uploading company data or
            other banned files.
          </p>
        </Dragger>
      </Card>
      <Card title="文件下载" loading={!value}>
        <div>文件地址:{COS_HOST + value}</div>
        <Divider />
        <img src={COS_HOST + value} height={280} />
        <Divider />
        <Button
          onClick={async () => {
            const blob = await testDownloadFileUsingGet(
              {
                filepath: value,
              },
              {
                responseType: 'blob',
              },
            );
            // 使用 file-saver 来保存文件
            const fullPath = COS_HOST + value;
            saveAs(blob, fullPath.substring(fullPath.lastIndexOf('/') + 1));
          }}
        >
          点击下载文件
        </Button>
      </Card>
    </Flex>
  );
};

export default TestFilePage;
  1. 测试文件上传、显示和下载
0bc92eeb489149bf89fec361482c2cc9

3. 创建代码生成器功能

先开发后端,创建代码生成器页面依赖的接口如下:

  1. 创建代码生成器
  2. 文件上传,包括上传代码生成器的图片和 dist 产物包
    • 现在得到的代码生成器成品是一个 dist 目录,包含多个文件。上传、下载多文件不方便,所以需要对目录进行压缩打包

1. 文件压缩打包

如何压缩打包文件呢?有 2 种方案:

  1. 使用 COS 自带的能力,上传文件后执行压缩打包任务。文档中心>对象存储>SDK 文档>Java SDK>文件处理>open in new window
  2. 在制作工具生成代码生成器产物包时,同时得到一个压缩包文件。更推荐这种方式

首先修改制作工具 maker 项目的 GenerateTemplate.java 文件。新增一个制作压缩包的方法,可供子类调用

/**
 * 制作压缩包
 *
 * @param outputPath
 * @return 压缩包路径
 */
protected String buildZip(String outputPath) {
    String zipPath = outputPath + ".zip";
    ZipUtil.zip(outputPath, zipPath);
    return zipPath;
}

然后修改模板类的 buildDist 方法,返回 dist 包的文件路径。同步修改 MainGenerator 的返回值

/**
 * 生成精简版程序
 * @return 产物包路径
 */
protected String buildDist(String outputPath, String sourceCopyDestPath, String jarPath, String shellOutputFilePath) {
    String distOutputPath = outputPath + "-dist";
    // 拷贝 jar 包
    String targetAbsolutePath = distOutputPath + File.separator + "target";
    FileUtil.mkdir(targetAbsolutePath);
    String jarAbsolutePath = outputPath + File.separator + jarPath;
    FileUtil.copy(jarAbsolutePath, targetAbsolutePath, true);
    // 拷贝脚本文件
    FileUtil.copy(shellOutputFilePath, distOutputPath, true);
    // 拷贝源模板文件
    FileUtil.copy(sourceCopyDestPath, distOutputPath, true);
    return distOutputPath;
}

maker.generator.main 包下,新增压缩包生成器 ZipGenerator 子类,同时生成产物包和压缩包

package com.yupi.maker.generator.main;

/**
 * 生成代码生成器压缩包
 */
public class ZipGenerator extends GenerateTemplate {

    @Override
    protected String buildDist(String outputPath, String sourceCopyDestPath, String jarPath, String shellOutputFilePath) {
        String distPath = super.buildDist(outputPath, sourceCopyDestPath, jarPath, shellOutputFilePath);
        return super.buildZip(distPath);
    }
}

最后修改主类的 main 方法,测试生成代码生成器的压缩包

public class Main {

    public static void main(String[] args) throws TemplateException, IOException, InterruptedException {
        // GenerateTemplate generateTemplate = new MainGenerator();
        GenerateTemplate generateTemplate = new ZipGenerator();
        generateTemplate.doGenerate();
    }
}
afe02dc9ddb84bdf8a01e8fe8e0a196d

2. 文件上传接口

FileController 中编写文件上传接口

  • 修改方法的返回值,不再拼接 FileConstant.COS_HOST,而是直接返回 filepath 相对路径,便于后续直接根据 filepath 下载
  • 更方便地管理文件,引入了 biz 参数,用来区分业务,不同业务的文件上传到不同的目录中。后面甚至还可以根据目录来设置不同的访问权限,提高安全性
/**
 * 文件上传
 *
 * @param multipartFile
 * @param uploadFileRequest
 * @param request
 * @return
 */
@PostMapping("/upload")
public BaseResponse<String> uploadFile(@RequestPart("file") MultipartFile multipartFile,
                                       UploadFileRequest uploadFileRequest, HttpServletRequest request) {
    String biz = uploadFileRequest.getBiz();
    FileUploadBizEnum fileUploadBizEnum = FileUploadBizEnum.getEnumByValue(biz);
    if (fileUploadBizEnum == null) {
        throw new BusinessException(ErrorCode.PARAMS_ERROR);
    }
    validFile(multipartFile, fileUploadBizEnum);
    User loginUser = userService.getLoginUser(request);
    // 文件目录:根据业务、用户来划分
    String uuid = RandomStringUtils.randomAlphanumeric(8);
    String filename = uuid + "-" + multipartFile.getOriginalFilename();
    String filepath = String.format("/%s/%s/%s", fileUploadBizEnum.getValue(), loginUser.getId(), filename);
    File file = null;
    try {
        // 上传文件
        file = File.createTempFile(filepath, null);
        multipartFile.transferTo(file);
        cosManager.putObject(filepath, file);
        // 返回可访问地址
        return ResultUtils.success(filepath);
    } catch (Exception e) {
        log.error("file upload error, filepath = " + filepath, e);
        throw new BusinessException(ErrorCode.SYSTEM_ERROR, "上传失败");
    } finally {
        if (file != null) {
            // 删除临时文件
            boolean delete = file.delete();
            if (!delete) {
                log.error("file delete error, filepath = {}", filepath);
            }
        }
    }
}

修改 FileUploadBizEnum 枚举类,增加几种业务类型

public enum FileUploadBizEnum {

    USER_AVATAR("用户头像", "user_avatar"),
    GENERATOR_PICTURE("生成器图片", "generator_picture"),
    GENERATOR_DIST("生成器产物包", "generator_dist");

    private final String text;

    private final String value;

    FileUploadBizEnum(String text, String value) {
        this.text = text;
        this.value = value;
    }

    /**
     * 获取值列表
     *
     * @return
     */
    public static List<String> getValues() {
        return Arrays.stream(values()).map(item -> item.value).collect(Collectors.toList());
    }

    /**
     * 根据 value 获取枚举
     *
     * @param value
     * @return
     */
    public static FileUploadBizEnum getEnumByValue(String value) {
        if (ObjectUtils.isEmpty(value)) {
            return null;
        }
        for (FileUploadBizEnum anEnum : FileUploadBizEnum.values()) {
            if (anEnum.value.equals(value)) {
                return anEnum;
            }
        }
        return null;
    }

    public String getValue() {
        return value;
    }

    public String getText() {
        return text;
    }
}

3. 通用文件上传组件

了解 Ant Design 组件库的运行机制

  • eg:此处需要遵循自定义表单控件的 规范open in new window.根据规范,我们要给组件指定 valueonChange 两个属性
  • 分别需要开发文件上传和图片上传 2 个组件

1. 文件上传组件

组件接收值类型为 UploadFile[] 文件列表,还可以接受外层传来的描述(description),让用户自定义描述信息

26c18b4be6944105b2877958d430e233
import { uploadFileUsingPost } from '@/services/backend/fileController';
import { InboxOutlined } from '@ant-design/icons';
import { message, UploadFile, UploadProps } from 'antd';
import Dragger from 'antd/es/upload/Dragger';
import React, { useState } from 'react';

interface Props {
  biz: string;
  onChange?: (fileList: UploadFile[]) => void;
  value?: UploadFile[];
  description?: string;
}

/**
 * 文件上传组件
 * @constructor
 */
const FileUploader: React.FC<Props> = (props) => {
  const { biz, value, description, onChange } = props;
  const [loading, setLoading] = useState(false);

  const uploadProps: UploadProps = {
    name: 'file',
    listType: 'text',
    multiple: false,
    maxCount: 1,
    fileList: value,
    disabled: loading,
    onChange: ({ fileList }) => {
      onChange?.(fileList);
    },
    customRequest: async (fileObj: any) => {
      setLoading(true);
      try {
        const res = await uploadFileUsingPost(
          {
            biz,
          },
          {},
          fileObj.file,
        );
        fileObj.onSuccess(res.data);
      } catch (e: any) {
        message.error('上传失败,' + e.message);
        fileObj.onError(e);
      }
      setLoading(false);
    },
  };

  return (
    <Dragger {...uploadProps}>
      <p className="ant-upload-drag-icon">
        <InboxOutlined />
      </p>
      <p className="ant-upload-text">点击或拖拽文件上传</p>
      <p className="ant-upload-hint">{description}</p>
    </Dragger>
  );
};

export default FileUploader;

数据库中存储的是产物包的 key,而不是文件对象。注意:在外层使用该组件时,要将文件对象和 key(或 url 地址)进行互转

604ab8b394504fd3809b5950e2f61fbc
734cb2d172d94b429d1316ad07dafa95

2. 图片上传

参考 Ant Design 现有的 图片上传组件open in new window

  • components 组件目录下新建 PictureUploader 组件
  • 相比于文件上传组件,增加了一个展示用户已上传的图片的逻辑。需要注意,文件上传接口返回的是相对路径,要拼接上 COS_HOST 前缀,才能得到图片的完整路径
35d61e9d8a4b49c4b1a3f8a0b60406f0
import { uploadFileUsingPost } from '@/services/backend/fileController';
import { LoadingOutlined, PlusOutlined } from '@ant-design/icons';
import { message, Upload, UploadProps } from 'antd';
import React, { useState } from 'react';
import {COS_HOST} from "@/constants";

interface Props {
  biz: string;
  onChange?: (url: string) => void;
  value?: string;
}

/**
 * 图片上传组件
 * @constructor
 */
const PictureUploader: React.FC<Props> = (props) => {
  const { biz, value, onChange } = props;
  const [loading, setLoading] = useState(false);

  const uploadProps: UploadProps = {
    name: 'file',
    listType: 'picture-card',
    multiple: false,
    maxCount: 1,
    showUploadList: false,
    customRequest: async (fileObj: any) => {
      setLoading(true);
      try {
        const res = await uploadFileUsingPost(
          {
            biz,
          },
          {},
          fileObj.file,
        );
        // 拼接完整图片路径
        const fullPath = COS_HOST + res.data;
        onChange?.(fullPath ?? '');
        fileObj.onSuccess(fullPath);
      } catch (e: any) {
        message.error('上传失败,' + e.message);
        fileObj.onError(e);
      }
      setLoading(false);
    },
  };

  const uploadButton = (
    <div>
      {loading ? <LoadingOutlined /> : <PlusOutlined />}
      <div style={{ marginTop: 8 }}>上传</div>
    </div>
  );

  return (
    <Upload {...uploadProps}>
      {value ? <img src={value} alt="picture" style={{ width: '100%' }} /> : uploadButton}
    </Upload>
  );
};

export default PictureUploader;

4. 创建页面开发

创建代码生成器时,需要填写的字段较多,所以使用分步表单。官方 Demoopen in new window

  1. 新建路由和对应的页面文件:
  {
    path: '/generator/add',
    icon: 'plus',
    component: './Generator/Add',
    name: '创建生成器',
  },
ff75670d68604c879dd0ff7abbf2d3f1
  1. 先根据 Ant Design Procomponents 的分步表单组件,完成基本表单,实现基本的分步流程,并尝试输出用户填写的全部参数
    • 先不编写 fileConfig 和 modelConfig 这些结构复杂的表单项
    • 使用了通用上传组件,并通过指定不同的 biz 参数,控制不同类别的文件上传到不同目录中
import FileUploader from '@/components/FileUploader';
import PictureUploader from '@/components/PictureUploader';
import { addGeneratorUsingPost } from '@/services/backend/generatorController';
import type { ProFormInstance } from '@ant-design/pro-components';
import {
  ProCard,
  ProFormSelect,
  ProFormText,
  ProFormTextArea,
  StepsForm,
} from '@ant-design/pro-components';
import { ProFormItem } from '@ant-design/pro-form';
import { message } from 'antd';
import React, { useRef } from 'react';

/**
 * 创建生成器页面
 * @constructor
 */
const GeneratorAddPage: React.FC = () => {
  const formRef = useRef<ProFormInstance>();

  return (
    <ProCard>
      <StepsForm<API.GeneratorAddRequest> formRef={formRef}>
        <StepsForm.StepForm
          name="base"
          title="基本信息"
          onFinish={async () => {
            console.log(formRef.current?.getFieldsValue());
            return true;
          }}
        >
          <ProFormText name="name" label="名称" placeholder="请输入名称" />
          <ProFormTextArea name="description" label="描述" placeholder="请输入描述" />
          <ProFormText name="basePackage" label="基础包" placeholder="请输入基础包" />
          <ProFormText name="version" label="版本" placeholder="请输入版本" />
          <ProFormText name="author" label="作者" placeholder="请输入作者" />
          <ProFormSelect label="标签" mode="tags" name="tags" placeholder="请输入标签列表" />
          <ProFormItem label="图片" name="picture">
            <PictureUploader biz="generator_picture" />
          </ProFormItem>
        </StepsForm.StepForm>
        <StepsForm.StepForm name="fileConfig" title="文件配置">
          {/*todo 待补充*/}
        </StepsForm.StepForm>
        <StepsForm.StepForm name="modelConfig" title="模型配置">
          {/*todo 待补充*/}
        </StepsForm.StepForm>
        <StepsForm.StepForm name="dist" title="生成器文件">
          <ProFormItem label="产物包" name="distPath">
            <FileUploader biz="generator_dist" description="请上传生成器文件压缩包" />
          </ProFormItem>
        </StepsForm.StepForm>
      </StepsForm>
    </ProCard>
  );
};

export default GeneratorAddPage;
  1. 开发完基本页面后,编写提交函数,对用户填写的数据进行校验和转换(eg:将文件列表转换为 url),然后请求后端来创建生成器,创建成功后跳转到详情页
import FileUploader from '@/components/FileUploader';
import PictureUploader from '@/components/PictureUploader';
import { addGeneratorUsingPost } from '@/services/backend/generatorController';
import type { ProFormInstance } from '@ant-design/pro-components';
import {
  ProCard,
  ProFormSelect,
  ProFormText,
  ProFormTextArea,
  StepsForm,
} from '@ant-design/pro-components';
import { ProFormItem } from '@ant-design/pro-form';
import { history } from '@umijs/max';
import { message } from 'antd';
import React, { useRef } from 'react';

/**
 * 创建生成器页面
 * @constructor
 */
const GeneratorAddPage: React.FC = () => {
  const formRef = useRef<ProFormInstance>();

  /**
   * 提交
   * @param values
   */
  const doSubmit = async (values: API.GeneratorAddRequest) => {
    // 数据转换
    if (!values.fileConfig) {
      values.fileConfig = {};
    }
    if (!values.modelConfig) {
      values.modelConfig = {};
    }
    // 文件列表转 url
    if (values.distPath && values.distPath.length > 0) {
      // @ts-ignore
      values.distPath = values.distPath[0].response;
    }

    try {
      const res = await addGeneratorUsingPost(values);
      if (res.data) {
        message.success('创建成功');
        history.push(`/generator/detail/${res.data}`);
      }
    } catch (error: any) {
      message.error('创建失败,' + error.message);
    }
  };

  return (
    <ProCard>
      <StepsForm<API.GeneratorAddRequest> formRef={formRef} onFinish={doSubmit}>
        <StepsForm.StepForm
          name="base"
          title="基本信息"
          onFinish={async () => {
            console.log(formRef.current?.getFieldsValue());
            return true;
          }}
        >
          <ProFormText name="name" label="名称" placeholder="请输入名称" />
          <ProFormTextArea name="description" label="描述" placeholder="请输入描述" />
          <ProFormText name="basePackage" label="基础包" placeholder="请输入基础包" />
          <ProFormText name="version" label="版本" placeholder="请输入版本" />
          <ProFormText name="author" label="作者" placeholder="请输入作者" />
          <ProFormSelect label="标签" mode="tags" name="tags" placeholder="请输入标签列表" />
          <ProFormItem label="图片" name="picture">
            <PictureUploader biz="generator_picture" />
          </ProFormItem>
        </StepsForm.StepForm>
        <StepsForm.StepForm name="fileConfig" title="文件配置">
          {/*todo 待补充*/}
        </StepsForm.StepForm>
        <StepsForm.StepForm name="modelConfig" title="模型配置">
          {/*todo 待补充*/}
        </StepsForm.StepForm>
        <StepsForm.StepForm name="dist" title="生成器文件">
          <ProFormItem label="产物包" name="distPath">
            <FileUploader biz="generator_dist" description="请上传生成器文件压缩包" />
          </ProFormItem>
        </StepsForm.StepForm>
      </StepsForm>
    </ProCard>
  );
};

export default GeneratorAddPage;
477a314a2df04408b45d2383b2fca20a

5. 修改页面开发

直接在创建页面的基础上,支持读取老数据并修改的能力

  1. 新增修改页面路由,指向创建页面文件
  {
    path: '/generator/update',
    icon: 'plus',
    component: './Generator/Add',
    name: '修改生成器',
    hideInMenu: true,
  },
  1. 创建页面增加逻辑:通过 url 的查询参数传递要修改的数据 id,并且根据 id 查询老数据
    • 比较关键的是将 distPath 从路径转换为文件上传组件的 UploadFile 对象,用于将之前上传过的文件回显在文件上传组件中。其中,url(打开链接)要补充 COS_HOST 前缀,而 response(实际的值)不用补充
const [searchParams] = useSearchParams();
const id = searchParams.get('id');
const [oldData, setOldData] = useState<API.GeneratorEditRequest>();
const formRef = useRef<ProFormInstance>();

/**
 * 加载数据
 */
const loadData = async () => {
  if (!id) {
    return;
  }
  try {
    const res = await getGeneratorVoByIdUsingGet({
      id,
    });

    // 处理文件路径
    if (res.data) {
      const { distPath } = res.data ?? {};
      if (distPath) {
        // @ts-ignore
        res.data.distPath = [
          {
            uid: id,
            name: '文件' + id,
            status: 'done',
            url: COS_HOST + distPath,
            response: distPath,
          } as UploadFile,
        ];
      }
      setOldData(res.data);
    }
  } catch (error: any) {
    message.error('加载数据失败,' + error.message);
  }
};

useEffect(() => {
  if (id) {
    loadData();
  }
}, [id]);






















 
 
 
 
 
 
 
 
 













108e2519f0ba485faabb94d61b6b0c1e
  1. 区分创建和修改
    • 根据 id 是否存在来判断执行创建还是更新
/**
 * 创建
 * @param values
 */
const doAdd = async (values: API.GeneratorAddRequest) => {
  try {
    const res = await addGeneratorUsingPost(values);
    if (res.data) {
      message.success('创建成功');
      history.push(`/generator/detail/${res.data}`);
    }
  } catch (error: any) {
    message.error('创建失败,' + error.message);
  }
};

/**
 * 更新
 * @param values
 */
const doUpdate = async (values: API.GeneratorEditRequest) => {
  try {
    const res = await editGeneratorUsingPost(values);
    if (res.data) {
      message.success('更新成功');
      history.push(`/generator/detail/${id}`);
    }
  } catch (error: any) {
    message.error('更新失败,' + error.message);
  }
};

/**
 * 提交
 * @param values
 */
const doSubmit = async (values: API.GeneratorAddRequest) => {
  // 数据转换
  if (!values.fileConfig) {
    values.fileConfig = {};
  }
  if (!values.modelConfig) {
    values.modelConfig = {};
  }
  // 文件列表转 url
  if (values.distPath && values.distPath.length > 0) {
    // @ts-ignore
    values.distPath = values.distPath[0].response;
  }

  if (id) {
    await doUpdate({
      id,
      ...values,
    });
  } else {
    await doAdd(values);
  }
};


















































 
 
 
 
 
 
 
 

  1. 测试编写好的页面,会发现除了第一步之外的表单项,并没有回填默认值
    • 控制表单的渲染时机,等要更新的老数据加载完成后,才渲染表单
{/* 创建或者已加载要更新的数据时,才渲染表单,顺利填充默认值 */}
{(!id || oldData) && (
  <StepsForm<API.GeneratorAddRequest | API.GeneratorEditRequest>
    formRef={formRef}
    formProps={{
      initialValues: oldData,
    }}
    onFinish={doSubmit}
  >
import FileUploader from '@/components/FileUploader';
import PictureUploader from '@/components/PictureUploader';
import { COS_HOST } from '@/constants';
import {
  addGeneratorUsingPost,
  editGeneratorUsingPost,
  getGeneratorVoByIdUsingGet,
} from '@/services/backend/generatorController';
import { useSearchParams } from '@@/exports';
import type { ProFormInstance } from '@ant-design/pro-components';
import {
  ProCard,
  ProFormSelect,
  ProFormText,
  ProFormTextArea,
  StepsForm,
} from '@ant-design/pro-components';
import { ProFormItem } from '@ant-design/pro-form';
import { history } from '@umijs/max';
import { message, UploadFile } from 'antd';
import React, { useEffect, useRef, useState } from 'react';

/**
 * 创建生成器页面
 * @constructor
 */
const GeneratorAddPage: React.FC = () => {
  const [searchParams] = useSearchParams();
  const id = searchParams.get('id');
  const [oldData, setOldData] = useState<API.GeneratorEditRequest>();
  const formRef = useRef<ProFormInstance>();

  /**
   * 加载数据
   */
  const loadData = async () => {
    if (!id) {
      return;
    }
    try {
      const res = await getGeneratorVoByIdUsingGet({
        id,
      });

      // 处理文件路径
      if (res.data) {
        const { distPath } = res.data ?? {};
        if (distPath) {
          // @ts-ignore
          res.data.distPath = [
            {
              uid: id,
              name: '文件' + id,
              status: 'done',
              url: COS_HOST + distPath,
              response: distPath,
            } as UploadFile,
          ];
        }
        setOldData(res.data);
      }
    } catch (error: any) {
      message.error('加载数据失败,' + error.message);
    }
  };

  useEffect(() => {
    if (id) {
      loadData();
    }
  }, [id]);

  /**
   * 创建
   * @param values
   */
  const doAdd = async (values: API.GeneratorAddRequest) => {
    try {
      const res = await addGeneratorUsingPost(values);
      if (res.data) {
        message.success('创建成功');
        history.push(`/generator/detail/${res.data}`);
      }
    } catch (error: any) {
      message.error('创建失败,' + error.message);
    }
  };

  /**
   * 更新
   * @param values
   */
  const doUpdate = async (values: API.GeneratorEditRequest) => {
    try {
      const res = await editGeneratorUsingPost(values);
      if (res.data) {
        message.success('更新成功');
        history.push(`/generator/detail/${id}`);
      }
    } catch (error: any) {
      message.error('更新失败,' + error.message);
    }
  };

  /**
   * 提交
   * @param values
   */
  const doSubmit = async (values: API.GeneratorAddRequest) => {
    // 数据转换
    if (!values.fileConfig) {
      values.fileConfig = {};
    }
    if (!values.modelConfig) {
      values.modelConfig = {};
    }
    // 文件列表转 url
    if (values.distPath && values.distPath.length > 0) {
      // @ts-ignore
      values.distPath = values.distPath[0].response;
    }

    if (id) {
      await doUpdate({
        id,
        ...values,
      });
    } else {
      await doAdd(values);
    }
  };

  return (
    <ProCard>
      {/* 创建或者已加载要更新的数据时,才渲染表单,顺利填充默认值 */}
      {(!id || oldData) && (
        <StepsForm<API.GeneratorAddRequest | API.GeneratorEditRequest>
          formRef={formRef}
          formProps={{
            initialValues: oldData,
          }}
          onFinish={doSubmit}
        >
          <StepsForm.StepForm name="base" title="基本信息">
            <ProFormText name="name" label="名称" placeholder="请输入名称" />
            <ProFormTextArea name="description" label="描述" placeholder="请输入描述" />
            <ProFormText name="basePackage" label="基础包" placeholder="请输入基础包" />
            <ProFormText name="version" label="版本" placeholder="请输入版本" />
            <ProFormText name="author" label="作者" placeholder="请输入作者" />
            <ProFormSelect label="标签" mode="tags" name="tags" placeholder="请输入标签列表" />
            <ProFormItem label="图片" name="picture">
              <PictureUploader biz="generator_picture" />
            </ProFormItem>
          </StepsForm.StepForm>
          <StepsForm.StepForm name="fileConfig" title="文件配置">
            {/* todo 待补充 */}
          </StepsForm.StepForm>
          <StepsForm.StepForm name="modelConfig" title="模型配置">
            {/* todo 待补充 */}
          </StepsForm.StepForm>
          <StepsForm.StepForm name="dist" title="生成器文件">
            <ProFormItem label="产物包" name="distPath">
              <FileUploader biz="generator_dist" description="请上传生成器文件压缩包" />
            </ProFormItem>
          </StepsForm.StepForm>
        </StepsForm>
      )}
    </ProCard>
  );
};

export default GeneratorAddPage;

4. 详情页

展示代码生成器的详细信息,并且让用户下载生成器文件。依赖的后端接口

  1. 根据 id 获取生成器详情
  2. 根据 id 下载代码生成器文件

1. 下载生成器文件接口

  1. 做好权限控制(仅登录用户可下载),并打上下载日志
/**
 * 根据 id 下载
 *
 * @param id
 * @return
 */
@GetMapping("/download")
public void downloadGeneratorById(long id, HttpServletRequest request, HttpServletResponse response) throws IOException {
    if (id <= 0) {
        throw new BusinessException(ErrorCode.PARAMS_ERROR);
    }
    User loginUser = userService.getLoginUser(request);
    Generator generator = generatorService.getById(id);
    if (generator == null) {
        throw new BusinessException(ErrorCode.NOT_FOUND_ERROR);
    }

    String filepath = generator.getDistPath();
    if (StrUtil.isBlank(filepath)) {
        throw new BusinessException(ErrorCode.NOT_FOUND_ERROR, "产物包不存在");
    }

    // 追踪事件
    log.info("用户 {} 下载了 {}", loginUser, filepath);

    COSObjectInputStream cosObjectInput = null;
    try {
        COSObject cosObject = cosManager.getObject(filepath);
        cosObjectInput = cosObject.getObjectContent();
        // 处理下载到的流
        byte[] bytes = IOUtils.toByteArray(cosObjectInput);
        // 设置响应头
        response.setContentType("application/octet-stream;charset=UTF-8");
        response.setHeader("Content-Disposition", "attachment; filename=" + filepath);
        // 写入响应
        response.getOutputStream().write(bytes);
        response.getOutputStream().flush();
    } catch (Exception e) {
        log.error("file download error, filepath = " + filepath, e);
        throw new BusinessException(ErrorCode.SYSTEM_ERROR, "下载失败");
    } finally {
        if (cosObjectInput != null) {
            cosObjectInput.close();
        }
    }
}

2. 详情页面开发

  1. 先定义路由,需要将路径指定为动态的,根据生成器的 id 加载不同内容
{
  path: '/generator/detail/:id',
  icon: 'home',
  component: './Generator/Detail',
  name: '生成器详情',
  hideInMenu: true,
},
770ef0a36d534c04b0008d3c6a9ea065
  1. 在详情页中,可以通过 useParams 钩子函数获取到动态路由的 id,获取到生成器的信息
const { id } = useParams();
const [loading, setLoading] = useState<boolean>(false);
const [data, setData] = useState<API.GeneratorVO>({});

const loadData = async () => {
  if (!id) {
    return;
  }
  setLoading(true);
  try {
    const res = await getGeneratorVoByIdUsingGet({
      id,
    });
    setData(res.data || {});
  } catch (error: any) {
    message.error('获取数据失败,' + error.message);
  }
  setLoading(false);
};

useEffect(() => {
  loadData();
}, [id]);
  1. 自上而下开发页面,展示信息即可
    • 可以先编写基本的结构,具体下载功能的实现、详细配置最后再写
    • 页面上半部分展示生成器的基本信息、以及一些操作按钮
    • 下半部分展示详细配置和作者信息。3 个 tab 栏,可以分别将每个 tab 栏的内容定义为组件,父页面就很干净
<Card>
  <Row justify="space-between" gutter={[32, 32]}>
    <Col flex="auto">
      <Space size="large" align="center">
        <Typography.Title level={4}>{data.name}</Typography.Title>
        {tagListView(data.tags)}
      </Space>
      <Typography.Paragraph>{data.description}</Typography.Paragraph>
      <Typography.Paragraph type="secondary">
        创建时间:{moment(data.createTime).format('YYYY-MM-DD hh:mm:ss')}
      </Typography.Paragraph>
      <Typography.Paragraph type="secondary">基础包:{data.basePackage}</Typography.Paragraph>
      <Typography.Paragraph type="secondary">版本:{data.version}</Typography.Paragraph>
      <Typography.Paragraph type="secondary">作者:{data.author}</Typography.Paragraph>
      <div style={{ marginBottom: 24 }} />
      <Space size="middle">
        <Button type="primary">立即使用</Button>
        <Button icon={<DownloadOutlined />}>下载</Button>
      </Space>
    </Col>
    <Col flex="320px">
      <Image src={data.picture} />
    </Col>
  </Row>
</Card>
2271536504e54994862484db8552f5c8
<Tabs
  size="large"
  defaultActiveKey={'fileConfig'}
  onChange={() => {}}
  items={[
    {
      key: 'fileConfig',
      label: '文件配置',
      children: <FileConfig data={data} />,
    },
    {
      key: 'modelConfig',
      label: '模型配置',
      children: <ModelConfig data={data} />,
    },
    {
      key: 'userInfo',
      label: '作者信息',
      children: <AuthorInfo data={data} />,
    },
  ]}
/>
9f12d6f6c30a48579d9327d60f72da6a
  1. 开发详细信息组件

1. FileConfig组件

import { FileOutlined, InfoCircleOutlined } from '@ant-design/icons';
import { Descriptions, DescriptionsProps, Divider } from 'antd';
import React from 'react';

interface Props {
  data: API.GeneratorVO;
}

/**
 * 文件配置
 * @constructor
 */
const FileConfig: React.FC<Props> = (props) => {
  const { data } = props;

  const fileConfig = data?.fileConfig;
  if (!fileConfig) {
    return <></>;
  }

  const items: DescriptionsProps['items'] = [
    {
      key: 'inputRootPath',
      label: '输入根路径',
      children: <p>{fileConfig.inputRootPath}</p>,
    },
    {
      key: 'outputRootPath',
      label: '输出根路径',
      children: <p>{fileConfig.outputRootPath}</p>,
    },
    {
      key: 'sourceRootPath',
      label: '项目根路径',
      children: <p>{fileConfig.sourceRootPath}</p>,
    },
    {
      key: 'type',
      label: '文件类别',
      children: <p>{fileConfig.type}</p>,
    },
  ];

  const fileListView = (files?: API.FileInfo[]) => {
    if (!files) {
      return <></>;
    }

    return (
      <>
        {files.map((file, index) => {
          // 是分组
          if (file.groupKey) {
            const groupFileItems: DescriptionsProps['items'] = [
              {
                key: 'groupKey',
                label: '分组key',
                children: <p>{file.groupKey}</p>,
              },
              {
                key: 'groupName',
                label: '分组名',
                children: <p>{file.groupName}</p>,
              },
              {
                key: 'condition',
                label: '条件',
                children: <p>{file.condition}</p>,
              },
              {
                key: 'files',
                label: '组内文件',
                children: <p>{fileListView(file.files)}</p>,
              },
            ];

            return (
              <Descriptions key={index} column={1} title={file.groupName} items={groupFileItems} />
            );
          }

          const fileItems: DescriptionsProps['items'] = [
            {
              key: 'inputPath',
              label: '输入路径',
              children: <p>{file.inputPath}</p>,
            },
            {
              key: 'outputPath',
              label: '输出路径',
              children: <p>{file.outputPath}</p>,
            },
            {
              key: 'type',
              label: '文件类别',
              children: <p>{file.type}</p>,
            },
            {
              key: 'generateType',
              label: '文件生成类别',
              children: <p>{file.generateType}</p>,
            },
            {
              key: 'condition',
              label: '条件',
              children: <p>{file.condition}</p>,
            },
          ];

          return (
            <>
              <Descriptions column={2} key={index} items={fileItems} />
              <Divider />
            </>
          );
        })}
      </>
    );
  };

  return (
    <div>
      <Descriptions
        title={
          <>
            <InfoCircleOutlined /> 基本信息
          </>
        }
        column={2}
        items={items}
      />
      <div style={{ marginBottom: 16 }} />
      <Descriptions
        title={
          <>
            <FileOutlined /> 文件列表
          </>
        }
      />
      {fileListView(fileConfig.files)}
    </div>
  );
};

export default FileConfig;

2. ModelConfig组件

import { FileOutlined } from '@ant-design/icons';
import { Descriptions, DescriptionsProps, Divider } from 'antd';
import React from 'react';

interface Props {
  data: API.GeneratorVO;
}

/**
 * 模型配置
 * @constructor
 */
const ModelConfig: React.FC<Props> = (props) => {
  const { data } = props;

  const modelConfig = data?.modelConfig;
  if (!modelConfig) {
    return <></>;
  }

  const modelListView = (models?: API.ModelInfo[]) => {
    if (!models) {
      return <></>;
    }

    return (
      <>
        {models.map((model, index) => {
          // 是分组
          if (model.groupKey) {
            const groupModelItems: DescriptionsProps['items'] = [
              {
                key: 'groupKey',
                label: '分组key',
                children: <p>{model.groupKey}</p>,
              },
              {
                key: 'groupName',
                label: '分组名',
                children: <p>{model.groupName}</p>,
              },
              {
                key: 'condition',
                label: '条件',
                children: <p>{model.condition}</p>,
              },
              {
                key: 'models',
                label: '组内模型',
                children: <p>{modelListView(model.models)}</p>,
              },
            ];

            return (
              <Descriptions
                key={index}
                column={1}
                title={model.groupName}
                items={groupModelItems}
              />
            );
          }

          const modelItems: DescriptionsProps['items'] = [
            {
              key: 'fieldName',
              label: '字段名称',
              children: <p>{model.fieldName}</p>,
            },
            {
              key: 'type',
              label: '类型',
              children: <p>{model.type}</p>,
            },
            {
              key: 'description',
              label: '描述',
              children: <p>{model.description}</p>,
            },
            {
              key: 'defaultValue',
              label: '默认值',
              children: <p>{model.defaultValue as any}</p>,
            },
            {
              key: 'abbr',
              label: '缩写',
              children: <p>{model.abbr}</p>,
            },
            {
              key: 'condition',
              label: '条件',
              children: <p>{model.condition}</p>,
            },
          ];

          return (
            <>
              <Descriptions column={2} key={index} items={modelItems} />
              <Divider />
            </>
          );
        })}
      </>
    );
  };

  return (
    <div>
      <Descriptions
        title={
          <>
            <FileOutlined /> 模型列表
          </>
        }
      />
      {modelListView(modelConfig.models)}
    </div>
  );
};

export default ModelConfig;

3. AuthorInfo

作者信息组件

import { Avatar, Card } from 'antd';
import React from 'react';

interface Props {
  data: API.GeneratorVO;
}

/**
 * 作者信息
 * @constructor
 */
const AuthorInfo: React.FC<Props> = (props) => {
  const { data } = props;

  const user = data?.user;

  if (!user) {
    return <></>;
  }

  return (
    <div style={{ marginTop: 16 }}>
      <Card.Meta
        avatar={<Avatar size={64} src={user.userAvatar} />}
        title={user.userName}
        description={user.userProfile}
      />
    </div>
  );
};

export default AuthorInfo;

3. 下载功能实现

注意:按钮显隐的控制。eg:只有存在 distPath 代码包,才能下载;只有本人才能修改

/**
 * 下载按钮
 */
const downloadButton = data.distPath && currentUser && (
  <Button
    icon={<DownloadOutlined />}
    onClick={async () => {
      const blob = await downloadGeneratorByIdUsingGet(
        {
          id: data.id,
        },
        {
          responseType: 'blob',
        },
      );
      // 使用 file-saver 来保存文件
      const fullPath = data.distPath || '';
      saveAs(blob, fullPath.substring(fullPath.lastIndexOf('/') + 1));
    }}
  >
    下载
  </Button>
);

/**
 * 编辑按钮
 */
const editButton = my && (
  <Link to={`/generator/update?id=${data.id}`}>
    <Button icon={<EditOutlined />}>编辑</Button>
  </Link>
);

详情页中引用这两个按钮组件

import AuthorInfo from '@/pages/Generator/Detail/components/AuthorInfo';
import FileConfig from '@/pages/Generator/Detail/components/FileConfig';
import ModelConfig from '@/pages/Generator/Detail/components/ModelConfig';
import {
  downloadGeneratorByIdUsingGet,
  getGeneratorVoByIdUsingGet,
} from '@/services/backend/generatorController';
import { Link, useModel, useParams } from '@@/exports';
import { DownloadOutlined, EditOutlined } from '@ant-design/icons';
import { PageContainer } from '@ant-design/pro-components';
import { Button, Card, Col, Image, message, Row, Space, Tabs, Tag, Typography } from 'antd';
import { saveAs } from 'file-saver';
import moment from 'moment';
import React, { useEffect, useState } from 'react';

/**
 * 生成器详情页
 * @constructor
 */
const GeneratorDetailPage: React.FC = () => {
  const { id } = useParams();

  const [loading, setLoading] = useState<boolean>(false);
  const [data, setData] = useState<API.GeneratorVO>({});
  const { initialState } = useModel('@@initialState');
  const { currentUser } = initialState ?? {};
  const my = currentUser?.id === data?.userId;

  /**
   * 加载数据
   */
  const loadData = async () => {
    if (!id) {
      return;
    }
    setLoading(true);
    try {
      const res = await getGeneratorVoByIdUsingGet({
        id,
      });
      setData(res.data || {});
    } catch (error: any) {
      message.error('获取数据失败,' + error.message);
    }
    setLoading(false);
  };

  useEffect(() => {
    loadData();
  }, [id]);

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

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

  /**
   * 下载按钮
   */
  const downloadButton = data.distPath && currentUser && (
    <Button
      icon={<DownloadOutlined />}
      onClick={async () => {
        const blob = await downloadGeneratorByIdUsingGet(
          {
            id: data.id,
          },
          {
            responseType: 'blob',
          },
        );
        // 使用 file-saver 来保存文件
        const fullPath = data.distPath || '';
        saveAs(blob, fullPath.substring(fullPath.lastIndexOf('/') + 1));
      }}
    >
      下载
    </Button>
  );

  /**
   * 编辑按钮
   */
  const editButton = my && (
    <Link to={`/generator/update?id=${data.id}`}>
      <Button icon={<EditOutlined />}>编辑</Button>
    </Link>
  );

  return (
    <PageContainer title={<></>} loading={loading}>
      <Card>
        <Row justify="space-between" gutter={[32, 32]}>
          <Col flex="auto">
            <Space size="large" align="center">
              <Typography.Title level={4}>{data.name}</Typography.Title>
              {tagListView(data.tags)}
            </Space>
            <Typography.Paragraph>{data.description}</Typography.Paragraph>
            <Typography.Paragraph type="secondary">
              创建时间:{moment(data.createTime).format('YYYY-MM-DD hh:mm:ss')}
            </Typography.Paragraph>
            <Typography.Paragraph type="secondary">基础包:{data.basePackage}</Typography.Paragraph>
            <Typography.Paragraph type="secondary">版本:{data.version}</Typography.Paragraph>
            <Typography.Paragraph type="secondary">作者:{data.author}</Typography.Paragraph>
            <div style={{ marginBottom: 24 }} />
            <Space size="middle">
              <Button type="primary">立即使用</Button>
              {downloadButton}
              {editButton}
            </Space>
          </Col>
          <Col flex="320px">
            <Image src={data.picture} />
          </Col>
        </Row>
      </Card>
      <div style={{ marginBottom: 24 }} />
      <Card>
        <Tabs
          size="large"
          defaultActiveKey={'fileConfig'}
          onChange={() => {}}
          items={[
            {
              key: 'fileConfig',
              label: '文件配置',
              children: <FileConfig data={data} />,
            },
            {
              key: 'modelConfig',
              label: '模型配置',
              children: <ModelConfig data={data} />,
            },
            {
              key: 'userInfo',
              label: '作者信息',
              children: <AuthorInfo data={data} />,
            },
          ]}
        />
      </Card>
    </PageContainer>
  );
};

export default GeneratorDetailPage
e2c4db7d641f4703afb7bb7960b25173

主页的生成器卡片增加跳转到详情页的链接

<Link to={`/generator/detail/${data.id}`}>
  <Card hoverable cover={<Image alt={data.name} src={data.picture} />}>
    ...
  </Card>
</Link>