11-在线使用

1. 需求分析

在线使用代码生成器,在表单页面输入数据模型的值,就能直接下载生成的代码

2. 核心设计

1. 业务流程

  1. 用户打开某个生成器的使用页面,从后端请求需要用户填写的数据模型
  2. 用户填写表单并提交,向后端发送请求
  3. 后端从数据库中查询生成器信息,得到生成器产物文件路径
  4. 后端从对象存储中下载生成器产物文件到本地
  5. 后端操作代码生成器,输入用户填写的数据,得到生成的代码
  6. 后端将生成的代码返回给用户,前端下载
580d0e97e1cd441ca1c629d839d7e525

2. 问题分析

  1. 生成器使用页面需要展示哪些表单项?数据模型信息从哪里来?
  2. web 后端怎么操作代码生成器文件去生成代码?

1. 数据模型从哪来?

最原始的数据模型信息肯定是由用户创建生成器时填写的,所以 完善创建生成器页面 的 “模型配置” 表单。有了模型配置,生成器使用页面就可以渲染出对应的表单项,供用户填写

2. 如何操作生成器?

  • 通过执行脚本文件、传入指定的参数、交互式输入、最终得到生成的代码
  • 支持通过读取 JSON 文件获取数据模型,并生成代码
    • web 后端项目将用户输入的数据模型值 JSON 保存为本地文件,然后将文件路径作为输入参数去执行生成器脚本了

3. 后端开发

后端开发工作分为:

  1. 单个代码生成器改造,支持 JSON 输入
  2. 修改制作工具,生成支持 JSON 输入的代码生成器
  3. 使用生成器接口开发

0. 示例数据

  • 插入一条代码生成器数据,便于后续测试
  • 在代码生成器详情页查看该测试数据
INSERT INTO my_db.generator (id, name, description, basePackage, version, author, tags, picture, fileConfig, modelConfig, distPath, status, userId, createTime, updateTime, isDelete) VALUES (18, 'acm-template-pro-generator', 'ACM 示例模板生成器', 'com.yupi', '1.0', 'yupi', '["Java"]', 'https://yuzi-1256524210.cos.ap-shanghai.myqcloud.com/generator_picture/1738875515482562562/U7uDBXC3-test (1).jpg', '{
    "files": [
      {
        "groupKey": "git",
        "groupName": "开源",
        "type": "group",
        "condition": "needGit",
        "files": [
          {
            "inputPath": ".gitignore",
            "outputPath": ".gitignore",
            "type": "file",
            "generateType": "static"
          },
          {
            "inputPath": "README.md",
            "outputPath": "README.md",
            "type": "file",
            "generateType": "static"
          }
        ]
      },
      {
        "inputPath": "src/com/yupi/acm/MainTemplate.java.ftl",
        "outputPath": "src/com/yupi/acm/MainTemplate.java",
        "type": "file",
        "generateType": "dynamic"
      }
    ]
  }', '{"models":[{"fieldName":"needGit","type":"boolean","description":"是否生成 .gitignore 文件","defaultValue":true},{"fieldName":"loop","type":"boolean","description":"是否生成循环","defaultValue":false,"abbr":"l"},{"type":"MainTemplate","description":"用于生成核心模板文件","groupKey":"mainTemplate","groupName":"核心模板","models":[{"fieldName":"author","type":"String","description":"作者注释","defaultValue":"yupi","abbr":"a"},{"fieldName":"outputText","type":"String","description":"输出信息","defaultValue":"sum = ","abbr":"o"}],"condition":"loop"}]}', '/generator_dist/1738875515482562562/kLbG2yGh-acm-template-pro-generator.zip', 0, 1738875515482562562, '2024-01-06 23:00:17', '2024-01-08 18:50:12', 0);

1. 单个代码生成器改造

在修改 maker 制作工具项目前,我们先从之前已经生成的单个代码生成器 acm-template-pro-generator 下手,让它能支持 JSON 输入并生成代码。

  1. 打开 ACM 模板代码生成器项目,在 cli.command 包下新增一个 JSON 生成命令类 JsonGenerateCommand
    • 定义一个文件路径(filePath)属性来接受 JSON 文件路径,在执行时读取该文件并转换为 DataModel, 调用 MainGenerator.doGenerate 生成代码即可
@Command(name = "json-generate", description = "读取 json 文件生成代码", mixinStandardHelpOptions = true)
@Data
public class JsonGenerateCommand implements Callable<Integer> {

    @Option(names = {"-f", "--file"}, arity = "0..1", description = "json 文件路径", interactive = true, echo = true)
    private String filePath;

    public Integer call() throws Exception {
        String jsonStr = FileUtil.readUtf8String(filePath);
        DataModel dataModel = JSONUtil.toBean(jsonStr, DataModel.class);
        MainGenerator.doGenerate(dataModel);
        return 0;
    }
}









 




  1. 修改 CommandExecutor 类,补充刚创建的子命令
@Command(name = "acm-template-pro-generator", mixinStandardHelpOptions = true)
public class CommandExecutor implements Runnable {

    private final CommandLine commandLine;

    {
        commandLine = new CommandLine(this)
                .addSubcommand(new GenerateCommand())
                .addSubcommand(new ConfigCommand())
                .addSubcommand(new ListCommand())
                .addSubcommand(new JsonGenerateCommand());
    }
}










 


  1. 新建测试文件 test.json
{
  "needGit": false,
  "loop": true,
  "mainTemplate": {
    "author": "laoYuPi",
    "outputText": "i said = "
  }
}
package com.yupi;

import com.yupi.cli.CommandExecutor;

public class Main {

    public static void main(String[] args) {
        CommandExecutor commandExecutor = new CommandExecutor();
        args = new String[]{"json-generate", "--file=/Users/yupi/Code/yuzi-generator/yuzi-generator-maker/generated/acm-template-pro-generator/test.json"};
        commandExecutor.doExecute(args);
    }
}

2. 修改制作工具

  1. 在资源文件的模板目录下新建 JSON 生成命令类对应的 FTL 文件 JsonGenerateCommand.java.ftl
package ${basePackage}.cli.command;

import cn.hutool.core.io.FileUtil;
import cn.hutool.json.JSONUtil;
import ${basePackage}.generator.MainGenerator;
import ${basePackage}.model.DataModel;
import lombok.Data;
import picocli.CommandLine.Command;
import picocli.CommandLine.Option;

import java.util.concurrent.Callable;


@Command(name = "json-generate", description = "读取 json 文件生成代码", mixinStandardHelpOptions = true)
@Data
public class JsonGenerateCommand implements Callable<Integer> {

    @Option(names = {"-f", "--file"}, arity = "0..1", description = "json 文件路径", interactive = true, echo = true)
    private String filePath;

    public Integer call() throws Exception {
        String jsonStr = FileUtil.readUtf8String(filePath);
        DataModel dataModel = JSONUtil.toBean(jsonStr, DataModel.class);
        MainGenerator.doGenerate(dataModel);
        return 0;
    }
}
  1. 修改 CommandExecutor.java.ftl 文件
import ${basePackage}.cli.command.JsonGenerateCommand;

{
    commandLine = new CommandLine(this)
            .addSubcommand(new GenerateCommand())
            .addSubcommand(new ConfigCommand())
            .addSubcommand(new ListCommand())
            .addSubcommand(new JsonGenerateCommand());
}
 






 

5aa766611abd4e148d35dca75d9ee6da
  1. 修改生成器制作类 GenerateTemplategenerateCode 方法,补充对新命令文件的生成
// cli.command.JsonGenerateCommand
inputFilePath = inputResourcePath + File.separator + "templates/java/cli/command/JsonGenerateCommand.java.ftl";
outputFilePath = outputBaseJavaPackagePath + "/cli/command/JsonGenerateCommand.java";
DynamicFileGenerator.doGenerate(inputFilePath , outputFilePath, meta);
  1. 进行测试,能够顺利生成符合要求的代码生成器

3. 使用生成器接口

  1. 定义接口:接受用户输入的模型参数,返回生成的文件
package com.yupi.web.model.dto.generator;

import lombok.Data;

import java.io.Serializable;
import java.util.Map;

/**
 * 使用代码生成器请求
 */
@Data
public class GeneratorUseRequest implements Serializable {

    /**
     * 生成器的 id
     */
    private Long id;

    /**
     * 数据模型
     */
    Map<String, Object> dataModel;

    private static final long serialVersionUID = 1L;
}
/**
 * 使用代码生成器
 */
@PostMapping("/use")
public void useGenerator(
    @RequestBody GeneratorUseRequest generatorUseRequest,
    HttpServletRequest request,
    HttpServletResponse response) {

}
  1. 从对象存储下载生成器压缩包
    1. 先获取到代码生成器存储路径
    2. 定义一个独立的工作空间,用来存放下载的生成器压缩包等临时文件
    3. 压缩包的下载
    4. 解压文件,得到用户上传的生成器文件
    5. 将用户输入的参数写入到 JSON 文件中
    6. 执行脚本
      • 找到代码生成器文件中的脚本路径,通过递归目录找到第一个名称为 generator 文件
      • 如果是非 windows 系统,还要添加可执行权限
      • windows 系统和其他操作系统执行脚本的规则不同,需要对路径进行转义
    7. 返回生成的代码结果压缩包
    8. 清理文件
      • 以异步直接清理整个工作空间
/**
 * 使用代码生成器
 *
 * @param generatorUseRequest
 * @param request
 * @param response
 * @return
 */
@PostMapping("/use")
public void useGenerator(@RequestBody GeneratorUseRequest generatorUseRequest, HttpServletRequest request, HttpServletResponse response) throws IOException {
    // 获取用户输入的请求参数
    Long id = generatorUseRequest.getId();
    Map<String, Object> dataModel = generatorUseRequest.getDataModel();

    // 需要用户登录
    User loginUser = userService.getLoginUser(request);
    log.info("userId = {} 使用了生成器 id = {}", loginUser.getId(), id);

    Generator generator = generatorService.getById(id);
    if (generator == null) {
        throw new BusinessException(ErrorCode.NOT_FOUND_ERROR);
    }

    // 生成器的存储路径
    String distPath = generator.getDistPath();
    if (StrUtil.isBlank(distPath)) {
        throw new BusinessException(ErrorCode.NOT_FOUND_ERROR, "产物包不存在");
    }

    // 从对象存储下载生成器的压缩包

    // 定义独立的工作空间
    String projectPath = System.getProperty("user.dir");
    String tempDirPath = String.format("%s/.temp/use/%s", projectPath, id);
    String zipFilePath = tempDirPath + "/dist.zip";

    if (!FileUtil.exist(zipFilePath)) {
        FileUtil.touch(zipFilePath);
    }

    try {
        cosManager.download(distPath, zipFilePath);
    } catch (Exception e) {
        throw new BusinessException(ErrorCode.SYSTEM_ERROR, "生成器下载失败");
    }

    // 解压压缩包,得到脚本文件
    File unzipDistDir = ZipUtil.unzip(zipFilePath);

    // 将用户输入的参数写到 json 文件中
    String dataModelFilePath = tempDirPath + "/dataModel.json";
    String jsonStr = JSONUtil.toJsonStr(dataModel);
    FileUtil.writeUtf8String(jsonStr, dataModelFilePath);

    // 执行脚本
    // 找到脚本文件所在路径
    // 要注意,如果不是 windows 系统,找 generator 文件而不是 bat
    File scriptFile = FileUtil.loopFiles(unzipDistDir, 2, null)
            .stream()
            .filter(file -> file.isFile()
                    && "generator.bat".equals(file.getName()))
            .findFirst()
            .orElseThrow(RuntimeException::new);

    // 添加可执行权限
    try {
        Set<PosixFilePermission> permissions = PosixFilePermissions.fromString("rwxrwxrwx");
        Files.setPosixFilePermissions(scriptFile.toPath(), permissions);
    } catch (Exception e) {

    }

    // 构造命令
    File scriptDir = scriptFile.getParentFile();
    // 注意,如果是 mac / linux 系统,要用 "./generator"
    String scriptAbsolutePath = scriptFile.getAbsolutePath().replace("\\", "/");
    String[] commands = new String[] {scriptAbsolutePath, "json-generate", "--file=" + dataModelFilePath};

    // 这里一定要拆分!
    ProcessBuilder processBuilder = new ProcessBuilder(commands);
    processBuilder.directory(scriptDir);

    try {
        Process process = processBuilder.start();

        // 读取命令的输出
        InputStream inputStream = process.getInputStream();
        BufferedReader reader = new BufferedReader(new InputStreamReader(inputStream));
        String line;
        while ((line = reader.readLine()) != null) {
            System.out.println(line);
        }

        // 等待命令执行完成
        int exitCode = process.waitFor();
        System.out.println("命令执行结束,退出码:" + exitCode);
    } catch (Exception e) {
        e.printStackTrace();
        throw new BusinessException(ErrorCode.SYSTEM_ERROR, "执行生成器脚本错误");
    }

    // 压缩得到的生成结果,返回给前端
    String generatedPath = scriptDir.getAbsolutePath() + "/generated";
    String resultPath = tempDirPath + "/result.zip";
    File resultFile = ZipUtil.zip(generatedPath, resultPath);

    // 设置响应头
    response.setContentType("application/octet-stream;charset=UTF-8");
    response.setHeader("Content-Disposition", "attachment; filename=" + resultFile.getName());
    Files.copy(resultFile.toPath(), response.getOutputStream());

    // 清理文件
    CompletableFuture.runAsync(() -> {
        FileUtil.del(tempDirPath);
    });
}

需要下载对象存储的文件到服务器,而不是前端,需要新写一个对象存储的通用下载方法。官方文档open in new window

  • CosManager 类补充下载方法,使用线程池提高下载效率
  • @PostConstruct 注解,用于等 Bean 加载完成后,初始化对象存储下载对象
// 复用对象
private TransferManager transferManager;

// bean 加载完成后执行
@PostConstruct
public void init() {
    // 执行初始化逻辑
    System.out.println("Bean initialized!");
    // 多线程并发上传下载
    ExecutorService threadPool = Executors.newFixedThreadPool(32);
    transferManager = new TransferManager(cosClient, threadPool);
}

/**
 * 下载对象到本地文件
 *
 * @param key
 * @param localFilePath
 * @return
 * @throws InterruptedException
 */
public Download download(String key, String localFilePath) throws InterruptedException {
    File downloadFile = new File(localFilePath);
    GetObjectRequest getObjectRequest = new GetObjectRequest(cosClientConfig.getBucket(), key);
    Download download = transferManager.download(getObjectRequest, downloadFile);
    // 同步等待下载完成
    download.waitForCompletion();
    return download;
}

4. 测试

通过 Swagger 测试整个流程能否正确跑通

fd115036ddfd41a3a4b87e12f0be4c88

4. 前端页面开发

1. 创建生成器的模型配置

1. 支持创建

模型配置的填写规则相对复杂,有 3 个要求:官方文档open in new window

  1. 能够动态添加或减少模型
  2. 支持选择添加模型或模型组
  3. 如果是模型组,组内支持嵌套多个模型
64af77686cd346feafb3b52e445c973d
  1. Generator/Add/components 目录下新建 ModelConfigForm.tsx 模型配置表单组件
    • 在创建页面中引用该组件
    • 为了便于测试,先 onFinish 输出表单填写结果,并返回 false 防止跳转到下一步
<StepsForm.StepForm name="modelConfig" title="模型配置" onFinish={async (values) => {
  console.log(values);
  return false;
}}>
  <ModelConfigForm formRef={formRef} />
</StepsForm.StepForm>
  1. 开发表单组件
    • 首先展示所有模型配置字段表单项,用 Space 套起各表单项,使它们展示在一行,便于用户填写
    • 无论字段是否属于某个分组,需要填写的表单项一致,可以抽象出一个单字段填写视图组件,便于复用
    • 注意:如果字段属于某个分组,要额外展示一个删除按钮
  /**
   * 单个字段表单视图
   * @param field
   * @param remove
   */
  const singleFieldFormView = (
    field: FormListFieldData,
    remove?: (index: number | number[]) => void,
  ) => {
    return (
      <Space key={field.key}>
        <Form.Item label="字段名称" name={[field.name, 'fieldName']}>
          <Input />
        </Form.Item>
        <Form.Item label="描述" name={[field.name, 'description']}>
          <Input />
        </Form.Item>
        <Form.Item label="类型" name={[field.name, 'type']}>
          <Input />
        </Form.Item>
        <Form.Item label="默认值" name={[field.name, 'defaultValue']}>
          <Input />
        </Form.Item>
        <Form.Item label="缩写" name={[field.name, 'abbr']}>
          <Input />
        </Form.Item>
        {remove && (
          <Button type="text" danger onClick={() => remove(field.name)}>
            删除
          </Button>
        )}
      </Space>
    );
  };
  1. 修改分组 / 未分组字段的层级关系,对应的 name 层级等
    • 对于最外层的表单列表项
    • 如果是组内的表单列表,name 层级要加上父字段(分组)的 name
<Form.List name={['modelConfig', 'models']}>
  ...
</Form.List>
<Form.List name={[field.name, 'models']}>
  ...
</Form.List>
  1. 区分单个字段和分组
    • 提供 “添加字段” 和 “添加分组” 按钮供用户选择。如果选择添加分组,需要设置 groupName 和 groupKey 默认值
<Button type="dashed" onClick={() => add()}>
  添加字段
</Button>
<Button
  type="dashed"
  onClick={() =>
    add({
      groupName: '分组',
      groupKey: 'group',
    })
  }
>
  添加分组
</Button>

同样,可以用 groupKey 是否存在来判断是分组还是单个字段。先从当前已填写的表单信息中获取 groupKey

const groupKey =
    formRef?.current?.getFieldsValue()?.modelConfig?.models?.[field.name]?.groupKey;

分组和单个字段需要填写的表单项是不同的,所以要根据 groupKey 展示不同的内容

{groupKey ? (
  <Space>
    <Form.Item label="分组key" name={[field.name, 'groupKey']}>
      <Input />
    </Form.Item>
    <Form.Item label="组名" name={[field.name, 'groupName']}>
      <Input />
    </Form.Item>
    <Form.Item label="类型" name={[field.name, 'type']}>
      <Input />
    </Form.Item>
    <Form.Item label="条件" name={[field.name, 'condition']}>
      <Input />
    </Form.Item>
  </Space>
) : (
  singleFieldFormView(field)
)}
  1. 测试创建
    • 填写表单后查看控制台的输出值,验证字段是否符合输入
cb9033a1de3d4b4e8ae9106a22f01eb7

2. 支持修改

测试修改已有代码生成器的模型配置,发现并没有渲染已填写的分组。

  • 通过 groupKey 来判断是否渲染分组,而表单刚加载完成。还没有到模型配置填写时,通过表单的值是读取不到已有的 modelConfig

注意,Ant Design Procomponents 分步表单组件中,通过 formRef.current.getFieldsValue 得到的值始终只有当前步骤的,不包括之前已填写的

可以增加一个逻辑,如果存在之前的数据,并且通过表单读取不到模型配置,就读取之前数据内的模型配置,实现降级。

  1. 给组件增加 oldData 属性,用于传递修改前的数据、并驱动视图更新
    • 组件属性定义
    • 创建页面增加属性传递
interface Props {
  formRef: any;
  oldData: any;
}
<ModelConfigForm formRef={formRef} oldData={oldData} />
  1. 修改获取 groupKey 的逻辑
{fields.map((field) => {
  const modelConfig =
    formRef?.current?.getFieldsValue()?.modelConfig ?? oldData?.modelConfig;
  const groupKey = modelConfig.models?.[field.name]?.groupKey;
  ...
})}

3. 完整代码

import { CloseOutlined } from '@ant-design/icons';
import { Button, Card, Form, FormListFieldData, Input, Space } from 'antd';

interface Props {
  formRef: any;
  oldData: any;
}

export default (props: Props) => {
  const { formRef, oldData } = props;

  /**
   * 单个字段表单视图
   * @param field
   * @param remove
   */
  const singleFieldFormView = (
    field: FormListFieldData,
    remove?: (index: number | number[]) => void,
  ) => {
    return (
      <Space key={field.key}>
        <Form.Item label="字段名称" name={[field.name, 'fieldName']}>
          <Input />
        </Form.Item>
        <Form.Item label="描述" name={[field.name, 'description']}>
          <Input />
        </Form.Item>
        <Form.Item label="类型" name={[field.name, 'type']}>
          <Input />
        </Form.Item>
        <Form.Item label="默认值" name={[field.name, 'defaultValue']}>
          <Input />
        </Form.Item>
        <Form.Item label="缩写" name={[field.name, 'abbr']}>
          <Input />
        </Form.Item>
        {remove && (
          <Button type="text" danger onClick={() => remove(field.name)}>
            删除
          </Button>
        )}
      </Space>
    );
  };

  return (
    <Form.List name={['modelConfig', 'models']}>
      {(fields, { add, remove }) => {
        return (
          <div style={{ display: 'flex', rowGap: 16, flexDirection: 'column' }}>
            {fields.map((field) => {
              const modelConfig =
                formRef?.current?.getFieldsValue()?.modelConfig ?? oldData?.modelConfig;
              const groupKey = modelConfig.models?.[field.name]?.groupKey;

              return (
                <Card
                  size="small"
                  title={groupKey ? '分组' : '未分组字段'}
                  key={field.key}
                  extra={
                    <CloseOutlined
                      onClick={() => {
                        remove(field.name);
                      }}
                    />
                  }
                >
                  {groupKey ? (
                    <Space>
                      <Form.Item label="分组key" name={[field.name, 'groupKey']}>
                        <Input />
                      </Form.Item>
                      <Form.Item label="组名" name={[field.name, 'groupName']}>
                        <Input />
                      </Form.Item>
                      <Form.Item label="类型" name={[field.name, 'type']}>
                        <Input />
                      </Form.Item>
                      <Form.Item label="条件" name={[field.name, 'condition']}>
                        <Input />
                      </Form.Item>
                    </Space>
                  ) : (
                    singleFieldFormView(field)
                  )}

                  {/* 组内字段 */}
                  {groupKey && (
                    <Form.Item label="组内字段">
                      <Form.List name={[field.name, 'models']}>
                        {(subFields, subOpt) => (
                          <div
                            style={{
                              display: 'flex',
                              flexDirection: 'column',
                              rowGap: 16,
                            }}
                          >
                            {subFields.map((subField) =>
                              singleFieldFormView(subField, subOpt.remove),
                            )}
                            <Button type="dashed" onClick={() => subOpt.add()} block>
                              添加组内字段
                            </Button>
                          </div>
                        )}
                      </Form.List>
                    </Form.Item>
                  )}
                </Card>
              );
            })}

            <Button type="dashed" onClick={() => add()}>
              添加字段
            </Button>
            <Button
              type="dashed"
              onClick={() =>
                add({
                  groupName: '分组',
                  groupKey: 'group',
                })
              }
            >
              添加分组
            </Button>
            <div style={{ marginBottom: 16 }} />
          </div>
        );
      }}
    </Form.List>
  );
};

2. 使用生成器页面

不一定需要单独的页面,可以直接作为代码生成器详情页的一个 Tab 栏目,让用户能够在同一个页面查看详情并使用。为了大家学习方便、页面更精简,才选择将该功能放到新页面开发

1. 开发

  1. 新增页面和路由
{
  path: '/generator/use/:id',
  icon: 'home',
  component: './Generator/Use',
  name: '使用生成器',
  hideInMenu: true,
},

使用生成器页面的核心布局和详情页基本一致

1433591a19af41c783081c3bc25af679
eb0d001e25f94f75a042b39df6f857bc
  1. 页面开发
    • 使用页面主要是引导用户填写模型参数表单,需要注意区分模型是否为分组
<Form form={form}>
  {models.map((model, index) => {
    // 是分组
    if (model.groupKey) {
      if (!model.models) {
        return <></>;
      }

      return (
        <Collapse
          key={index}
          style={{
            marginBottom: 24,
          }}
          items={[
            {
              key: index,
              label: model.groupName + '(分组)',
              children: model.models.map((subModel, index) => {
                return (
                  <Form.Item
                    key={index}
                    label={subModel.fieldName}
                    // @ts-ignore
                    name={[model.groupKey, subModel.fieldName]}
                  >
                    <Input placeholder={subModel.description} />
                  </Form.Item>
                );
              }),
            },
          ]}
          bordered={false}
          defaultActiveKey={[index]}
        />
      );
    }

    return (
      <Form.Item key={index} label={model.fieldName} name={model.fieldName}>
        <Input placeholder={model.description} />
      </Form.Item>
    );
  })}
</Form>

修改下载按钮请求的接口为 “使用生成器”,从用户填写的表单中获取参数并调用。由于下载时间可能比较长,用 loading 状态变量表示下载中

/**
 * 下载按钮
 */
const downloadButton = data.distPath && currentUser && (
  <Button
    type="primary"
    icon={<DownloadOutlined />}
    loading={downloading}
    onClick={async () => {
      setDownloading(true);
      const values = form.getFieldsValue();

      // eslint-disable-next-line react-hooks/rules-of-hooks
      const blob = await useGeneratorUsingPost(
        {
          id: data.id,
          dataModel: values,
        },
        {
          responseType: 'blob',
        },
      );
      // 使用 file-saver 来保存文件
      const fullPath = data.distPath || '';
      saveAs(blob, fullPath.substring(fullPath.lastIndexOf('/') + 1));
      setDownloading(false);
    }}
  >
    生成代码
  </Button>
);

由于这个接口返回的也是 blob,缺少一般接口响应的 code 值。所以要修改全局响应拦截器,直接通过响应类型判断

// 文件下载时,直接返回
if (response.data instanceof Blob) {
  return response;
}
  1. 补充详情页和使用页的互相跳转逻辑
<Link to={`/generator/use/${id}`}>
  <Button type="primary">立即使用</Button>
</Link>
<Link to={`/generator/detail/${id}`}>
  <Button>查看详情</Button>
</Link>
  1. 测试能否生成符合预期的文件
6275e986e87e411ea11a5d0307eeaa04

2. 完整代码

import {
  getGeneratorVoByIdUsingGet,
  useGeneratorUsingPost,
} from '@/services/backend/generatorController';
import { useModel, useParams } from '@@/exports';
import { DownloadOutlined } from '@ant-design/icons';
import { PageContainer } from '@ant-design/pro-components';
import {
  Button,
  Card,
  Col,
  Collapse,
  Divider,
  Form,
  Image,
  Input,
  message,
  Row,
  Space,
  Typography,
} from 'antd';
import { saveAs } from 'file-saver';
import React, { useEffect, useState } from 'react';
import { Link } from 'umi';

/**
 * 生成器使用页面
 * @constructor
 */
const GeneratorUsePage: React.FC = () => {
  const { id } = useParams();
  const [form] = Form.useForm();

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

  const models = data?.modelConfig?.models ?? [];

  /**
   * 加载数据
   */
  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]);

  /**
   * 下载按钮
   */
  const downloadButton = data.distPath && currentUser && (
    <Button
      type="primary"
      icon={<DownloadOutlined />}
      loading={downloading}
      onClick={async () => {
        setDownloading(true);
        const values = form.getFieldsValue();

        // eslint-disable-next-line react-hooks/rules-of-hooks
        const blob = await useGeneratorUsingPost(
          {
            id: data.id,
            dataModel: values,
          },
          {
            responseType: 'blob',
          },
        );
        // 使用 file-saver 来保存文件
        const fullPath = data.distPath || '';
        saveAs(blob, fullPath.substring(fullPath.lastIndexOf('/') + 1));
        setDownloading(false);
      }}
    >
      生成代码
    </Button>
  );

  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>
            </Space>
            <Typography.Paragraph>{data.description}</Typography.Paragraph>
            <Divider />
            <Form form={form}>
              {models.map((model, index) => {
                // 是分组
                if (model.groupKey) {
                  if (!model.models) {
                    return <></>;
                  }

                  return (
                    <Collapse
                      key={index}
                      style={{
                        marginBottom: 24,
                      }}
                      items={[
                        {
                          key: index,
                          label: model.groupName + '(分组)',
                          children: model.models.map((subModel, index) => {
                            return (
                              <Form.Item
                                key={index}
                                label={subModel.fieldName}
                                // @ts-ignore
                                name={[model.groupKey, subModel.fieldName]}
                              >
                                <Input placeholder={subModel.description} />
                              </Form.Item>
                            );
                          }),
                        },
                      ]}
                      bordered={false}
                      defaultActiveKey={[index]}
                    />
                  );
                }

                return (
                  <Form.Item key={index} label={model.fieldName} name={model.fieldName}>
                    <Input placeholder={model.description} />
                  </Form.Item>
                );
              })}
            </Form>

            <div style={{ marginBottom: 24 }} />
            <Space size="middle">
              {downloadButton}
              <Link to={`/generator/detail/${id}`}>
                <Button>查看详情</Button>
              </Link>
            </Space>
          </Col>
          <Col flex="320px">
            <Image src={data.picture} />
          </Col>
        </Row>
      </Card>
    </PageContainer>
  );
};

export default GeneratorUsePage;

5. 扩展

  1. 使用页面优化模型参数的填写顺序和依赖关系,优先填写单个字段,再根据单个字段的值判断是否要填写模型组
  2. 根据字段的类型,区分填写值的表单组件,比如布尔类型的字段使用 Radio 单选组件
  3. 表单项自动填充模型配置中指定的默认值