--- title: "10-应用模块" created: 2025-12-02 tags: - 项目 aliases: - 应用模块 --- # 应用模块 ## 本节重点 在前面的章节中,我们成功实现了 AI 生成应用的基础功能,但目前的系统还是本地版本,缺乏完整的应用管理能力。本节我们将 **从简单的代码生成工具升级为完整的平台**,实现应用创建、生成、浏览、部署、管理的全流程。 本节主要内容: - 基础应用能力(增删改查) - 应用管理 - 应用生成 - 应用部署(3 种方案) - 前端页面生成和优化 友情提示,本节前‍端部分涉及大量 Vibe Co⁡ding,最好准备一款 AI ‎代码生成 IDE,效果差点没关‎系,主要学习的还是 AI 编程的思路和提示词编写技巧。 ## 一、需求分析 之前我们实现‍的是单机版本,用户只能在⁡本地生成代码文件。现在我‎们要将其升级为平台化系统‎,这意味着需要支持多用户、应用管理、在线部署等功能。 需要的具体功能包括: - 用户基础功能 - 创建应用 - 编辑应用信息 - 删除自己的应用 - 查看应用详情 - 分页查询自己的应用列表 - 分页查看精选应用列表 - 用户高级功能 - ⭐️ 实时查看应用效果 - ⭐️ 应用部署 - 管理功能 - 管理所有应用(删除、查询、修改) - 设置精选应用 功能还是比‍较多的,70% 的⁡应用功能,都将在本‎节完成,任务艰巨呀‎。 ## 二、方案设计 ### 核心业务流程 平台化改造的核心在于 **建立完整的应用生命周期管理体系**。 用户在主页输入提‍示词后,系统会创建一个应用记录⁡,然后跳转到对话页面与 AI ‎交互生成网站。生成完成后,用户‎可以预览效果,满意后进行部署,让网站真正对外提供服务。 这个流程看似‍简单,但涉及到数据存储⁡、权限控制、文件管理、‎网站部署等多个技术环节‎。我们需要设计合理的数据模型来支撑这些功能。 ### 库表设计 应用表是整‍个项目的核心,需要⁡记录应用的基本信息‎、生成配置、部署‎信息等。 其中最关键的是 `deployKey` 字段。由于每个网站应用文件的部署都是隔离的(想象成沙箱),需要用唯一字段来区分,可以作为应用的存储和访问路径;而且为了便于访问,每个应用的访问路径不能太长。 这里我们参‍考美团 NoCode⁡ 等平台的设计,将 ‎deployKey ‎设置为 6 位英文数字组成的唯一标识符。 app 表的建表 SQL 如下: ```sql -- 应用表 create table app ( id bigint auto_increment comment 'id' primary key, appName varchar(256) null comment '应用名称', cover varchar(512) null comment '应用封面', initPrompt text null comment '应用初始化的 prompt', codeGenType varchar(64) null comment '代码生成类型(枚举)', deployKey varchar(64) null comment '部署标识', deployedTime datetime null comment '部署时间', priority int default 0 not null comment '优先级', userId bigint not null comment '创建用户id', editTime datetime default CURRENT_TIMESTAMP not null comment '编辑时间', 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 '是否删除', UNIQUE KEY uk_deployKey (deployKey), -- 确保部署标识唯一 INDEX idx_appName (appName), -- 提升基于应用名称的查询性能 INDEX idx_userId (userId) -- 提升基于用户 ID 的查询性能 ) comment '应用' collate = utf8mb4_unicode_ci; ``` 这个设计中有几个值得注意的细节: 1)`priority` 优先级字段:我们约定 99 表示精选应用,这样可以在主页展示高质量的应用,避免用户看到大量测试内容。 为什么用数字‍而不用枚举类型呢?原因⁡是这样更利于扩展,比如‎约定 999 表示置顶‎;还可以根据数字灵活调整各个应用的具体展示顺序。 2)添加索引‍:给 deployKe⁡y、appName、u‎serId 三个经常用‎于作为查询条件的字段增加索引,提高查询性能。 注意,我们暂时‍不考虑将应用代码直接保存到数⁡据库字段中,而是保存在文件系‎统里。这样可以避免数据库和文‎件存储不一致的问题,也便于后续扩展到对象存储等方案。 --- 下面我们先‍把应用模块后端开发⁡完,再搞前端。  ‎         ‎ ## 三、基础应用能力(后端) 后端开发最‍终的目标是提供接口⁡,我们先来实现一些‎基础的接口,也就是‎ “增删改查”: - 【用户】创建应用(须填写 initPrompt) - 【用户】根据 id 修改自己的应用(目前只支持修改应用名称) - 【用户】根据 id 删除自己的应用 - 【用户】根据 id 查看应用详情 - 【用户】分页查询自己的应用列表(支持根据名称查询,每页最多 20 个) - 【用户】分页查询精选的应用列表(支持根据名称查询,每页最多 20 个) - 【管理员】根据 id 删除任意应用 - 【管理员】根据 id 更新任意应用(支持更新应用名称、应用封面、优先级) - 【管理员】分页查询应用列表(支持根据除时间外的任何字段查询,每页数量不限) - 【管理员】根据 id 查看应用详情 注意,需求描述‍地越清楚,后面开发越顺畅,⁡尤其是可以直接作为 AI 的提‎示词。        ‎ ### 基础代码生成 首先使用 ‍MyBatis F⁡lex 代码生成器‎生成基础的 CRU‎D 代码,这能大大提高开发效率。 生成代码后,移动代码到对应的位置: ![[image-25754d8c.png]] 然后修改 ‍App 实体类中 ⁡id 的生成方式,‎改为雪花算法: ```java @Id(keyType = KeyType.Generator, value = KeyGenerators.snowFlakeId) private Long id; ``` ### 业务代码生成 - Vibe Coding 由于之前已经开发过‍用户模块,项目内已经有了我们自己的⁡开发风格,可以作为 AI 的参‎考了。因此这次我们不再人工编写增删改查‎代码,而是将明确的需求描述提供给 AI,让它来生成业务代码。 在 Cursor 中打开 **后端项目根目录**,执行以下提示词: ```text 请参考项目中已有的 User 模块的文件和代码风格,帮我根据下列需求,生成完整的 App 模块的代码。 需要的功能如下: -【用户】创建应用(须填写 initPrompt) -【用户】根据 id 修改自己的应用(目前只支持修改应用名称) -【用户】根据 id 删除自己的应用 -【用户】根据 id 查看应用详情 -【用户】分页查询自己的应用列表(支持根据名称查询,每页最多 20 个) -【用户】分页查询精选的应用列表(支持根据名称查询,每页最多 20 个) -【管理员】根据 id 删除任意应用 -【管理员】根据 id 更新任意应用(支持更新应用名称、应用封面、优先级) -【管理员】分页查询应用列表(支持根据除时间外的任何字段查询,每页数量不限) -【管理员】根据 id 查看应用详情 ``` AI 会自‍动分析项目结构,生⁡成符合现有代码风格‎的完整业务代码: ![[image-23923719.png]] 每个人执行 AI 得到的结果应该是不同的,比如我这里是: ![[image-963aa881.png]] 接下来我们需要对照需求,逐一检查和完善生成的代码。 ### 核心功能实现 ### 创建应用 用户创建应‍用时,只需要填写初⁡始化提示词。系统会‎自动生成应用名称(‎取提示词前 12 位)和默认的代码生成类型。 请求类: ```java @Data public class AppAddRequest implements Serializable { /** * 应用初始化的 prompt */ private String initPrompt; private static final long serialVersionUID = 1L; } ``` 接口代码: ```java /** * 创建应用 * * @param appAddRequest 创建应用请求 * @param request 请求 * @return 应用 id */ @PostMapping("/add") public BaseResponse addApp(@RequestBody AppAddRequest appAddRequest, HttpServletRequest request) { ThrowUtils.throwIf(appAddRequest == null, ErrorCode.PARAMS_ERROR); // 参数校验 String initPrompt = appAddRequest.getInitPrompt(); ThrowUtils.throwIf(StrUtil.isBlank(initPrompt), ErrorCode.PARAMS_ERROR, "初始化 prompt 不能为空"); // 获取当前登录用户 User loginUser = userService.getLoginUser(request); // 构造入库对象 App app = new App(); BeanUtil.copyProperties(appAddRequest, app); app.setUserId(loginUser.getId()); // 应用名称暂时为 initPrompt 前 12 位 app.setAppName(initPrompt.substring(0, Math.min(initPrompt.length(), 12))); // 暂时设置为多文件生成 app.setCodeGenType(CodeGenTypeEnum.MULTI_FILE.getValue()); // 插入数据库 boolean result = appService.save(app); ThrowUtils.throwIf(!result, ErrorCode.OPERATION_ERROR); return ResultUtils.success(app.getId()); } ``` ### 更新应用 用户更新应用时,需要进行权限校验,确保只能修改自己的应用。 请求类: ```java @Data public class AppUpdateRequest implements Serializable { /** * id */ private Long id; /** * 应用名称 */ private String appName; private static final long serialVersionUID = 1L; } ``` 接口代码: ```java /** * 更新应用(用户只能更新自己的应用名称) * * @param appUpdateRequest 更新请求 * @param request 请求 * @return 更新结果 */ @PostMapping("/update") public BaseResponse updateApp(@RequestBody AppUpdateRequest appUpdateRequest, HttpServletRequest request) { if (appUpdateRequest == null || appUpdateRequest.getId() == null) { throw new BusinessException(ErrorCode.PARAMS_ERROR); } User loginUser = userService.getLoginUser(request); long id = appUpdateRequest.getId(); // 判断是否存在 App oldApp = appService.getById(id); ThrowUtils.throwIf(oldApp == null, ErrorCode.NOT_FOUND_ERROR); // 仅本人可更新 if (!oldApp.getUserId().equals(loginUser.getId())) { throw new BusinessException(ErrorCode.NO_AUTH_ERROR); } App app = new App(); app.setId(id); app.setAppName(appUpdateRequest.getAppName()); // 设置编辑时间 app.setEditTime(LocalDateTime.now()); boolean result = appService.updateById(app); ThrowUtils.throwIf(!result, ErrorCode.OPERATION_ERROR); return ResultUtils.success(true); } ``` 注意这里我们手动设置了 `editTime`,这是为了区分用户主动编辑和系统自动更新的时间。 ### 用户删除应用 需要进行权限校验,确保只能修改自己的应用。接口代码如下: ```java /** * 删除应用(用户只能删除自己的应用) * * @param deleteRequest 删除请求 * @param request 请求 * @return 删除结果 */ @PostMapping("/delete") public BaseResponse deleteApp(@RequestBody DeleteRequest deleteRequest, HttpServletRequest request) { if (deleteRequest == null || deleteRequest.getId() <= 0) { throw new BusinessException(ErrorCode.PARAMS_ERROR); } User loginUser = userService.getLoginUser(request); long id = deleteRequest.getId(); // 判断是否存在 App oldApp = appService.getById(id); ThrowUtils.throwIf(oldApp == null, ErrorCode.NOT_FOUND_ERROR); // 仅本人或管理员可删除 if (!oldApp.getUserId().equals(loginUser.getId()) && !UserConstant.ADMIN_ROLE.equals(loginUser.getUserRole())) { throw new BusinessException(ErrorCode.NO_AUTH_ERROR); } boolean result = appService.removeById(id); return ResultUtils.success(result); } ``` ### 用户查看应用详情 应用查询涉‍及到关联查询用户信⁡息,需要创建 Ap‎p 的封装类,包含‎ UserVO 用户信息字段: ```java @Data public class AppVO implements Serializable { /** * id */ private Long id; /** * 应用名称 */ private String appName; /** * 应用封面 */ private String cover; /** * 应用初始化的 prompt */ private String initPrompt; /** * 代码生成类型(枚举) */ private String codeGenType; /** * 部署标识 */ private String deployKey; /** * 部署时间 */ private LocalDateTime deployedTime; /** * 优先级 */ private Integer priority; /** * 创建用户id */ private Long userId; /** * 创建时间 */ private LocalDateTime createTime; /** * 更新时间 */ private LocalDateTime updateTime; /** * 创建用户信息 */ private UserVO user; private static final long serialVersionUID = 1L; } ``` AppService 中补充查询 App 关联信息的方法: ```java @Override public AppVO getAppVO(App app) { if (app == null) { return null; } AppVO appVO = new AppVO(); BeanUtil.copyProperties(app, appVO); // 关联查询用户信息 Long userId = app.getUserId(); if (userId != null) { User user = userService.getById(userId); UserVO userVO = userService.getUserVO(user); appVO.setUser(userVO); } return appVO; } ``` 接口代码中,先查询 App,再查询封装类: ```java /** * 根据 id 获取应用详情 * * @param id 应用 id * @return 应用详情 */ @GetMapping("/get/vo") public BaseResponse getAppVOById(long id) { ThrowUtils.throwIf(id <= 0, ErrorCode.PARAMS_ERROR); // 查询数据库 App app = appService.getById(id); ThrowUtils.throwIf(app == null, ErrorCode.NOT_FOUND_ERROR); // 获取封装类(包含用户信息) return ResultUtils.success(appService.getAppVO(app)); } ``` ### 用户分页查询应用 查询请求类,主要定义了可作为查询条件的字段: ```java @EqualsAndHashCode(callSuper = true) @Data public class AppQueryRequest extends PageRequest implements Serializable { /** * id */ private Long id; /** * 应用名称 */ private String appName; /** * 应用封面 */ private String cover; /** * 应用初始化的 prompt */ private String initPrompt; /** * 代码生成类型(枚举) */ private String codeGenType; /** * 部署标识 */ private String deployKey; /** * 优先级 */ private Integer priority; /** * 创建用户id */ private Long userId; private static final long serialVersionUID = 1L; } ``` 在 AppService 中添加构造查询对象的方法: ```java @Override public QueryWrapper getQueryWrapper(AppQueryRequest appQueryRequest) { if (appQueryRequest == null) { throw new BusinessException(ErrorCode.PARAMS_ERROR, "请求参数为空"); } Long id = appQueryRequest.getId(); String appName = appQueryRequest.getAppName(); String cover = appQueryRequest.getCover(); String initPrompt = appQueryRequest.getInitPrompt(); String codeGenType = appQueryRequest.getCodeGenType(); String deployKey = appQueryRequest.getDeployKey(); Integer priority = appQueryRequest.getPriority(); Long userId = appQueryRequest.getUserId(); String sortField = appQueryRequest.getSortField(); String sortOrder = appQueryRequest.getSortOrder(); return QueryWrapper.create() .eq("id", id) .like("appName", appName) .like("cover", cover) .like("initPrompt", initPrompt) .eq("codeGenType", codeGenType) .eq("deployKey", deployKey) .eq("priority", priority) .eq("userId", userId) .orderBy(sortField, "ascend".equals(sortOrder)); } ``` 分页查询应用‍时,也需要额外获取创建⁡应用的用户信息,这会涉‎及到关联查询多个用户信‎息,我们需要优化查询性能。优化查询逻辑如下: - 先收集所有 userId 到集合中 - 根据 userId 集合批量查询所有用户信息 - 构建 Map 映射关系 userId => UserVO - 一次性组装所有 AppVO,根据 userId 从 Map 中取到需要的用户信息 代码如下: ```java @Override public List getAppVOList(List appList) { if (CollUtil.isEmpty(appList)) { return new ArrayList<>(); } // 批量获取用户信息,避免 N+1 查询问题 Set userIds = appList.stream() .map(App::getUserId) .collect(Collectors.toSet()); Map userVOMap = userService.listByIds(userIds).stream() .collect(Collectors.toMap(User::getId, userService::getUserVO)); return appList.stream().map(app -> { AppVO appVO = getAppVO(app); UserVO userVO = userVOMap.get(app.getUserId()); appVO.setUser(userVO); return appVO; }).collect(Collectors.toList()); } ``` 💡 其实 ‍MyBatis Flex⁡ 提供了关联查询语法,但‎我们的实现方式会拆分查询‎阶段,让业务更清晰;直接构造关联查询适合更复杂的场景。 编写接口代‍码,注意限制用户只⁡能查询自己的应用、‎并且对返回结果进行‎封装: ```java /** * 分页获取当前用户创建的应用列表 * * @param appQueryRequest 查询请求 * @param request 请求 * @return 应用列表 */ @PostMapping("/my/list/page/vo") public BaseResponse> listMyAppVOByPage(@RequestBody AppQueryRequest appQueryRequest, HttpServletRequest request) { ThrowUtils.throwIf(appQueryRequest == null, ErrorCode.PARAMS_ERROR); User loginUser = userService.getLoginUser(request); // 限制每页最多 20 个 long pageSize = appQueryRequest.getPageSize(); ThrowUtils.throwIf(pageSize > 20, ErrorCode.PARAMS_ERROR, "每页最多查询 20 个应用"); long pageNum = appQueryRequest.getPageNum(); // 只查询当前用户的应用 appQueryRequest.setUserId(loginUser.getId()); QueryWrapper queryWrapper = appService.getQueryWrapper(appQueryRequest); Page appPage = appService.page(Page.of(pageNum, pageSize), queryWrapper); // 数据封装 Page appVOPage = new Page<>(pageNum, pageSize, appPage.getTotalRow()); List appVOList = appService.getAppVOList(appPage.getRecords()); appVOPage.setRecords(appVOList); return ResultUtils.success(appVOPage); } ``` ### 用户分页查询精选应用 这里参考了大厂‍的零代码应用生成平台,用户只⁡能在主页查询精选应用列表(还‎有自己的),这样主页会更干净‎;同时避免了爬虫,相当于起到了一个管理员审核的作用。 创建 `constant` 包和常量类 `AppConstant`,存储应用优先级常量: ```typescript public interface AppConstant { /** * 精选应用的优先级 */ Integer GOOD_APP_PRIORITY = 99; /** * 默认应用优先级 */ Integer DEFAULT_APP_PRIORITY = 0; } ``` 编写接口: ```java /** * 分页获取精选应用列表 * * @param appQueryRequest 查询请求 * @return 精选应用列表 */ @PostMapping("/good/list/page/vo") public BaseResponse> listGoodAppVOByPage(@RequestBody AppQueryRequest appQueryRequest) { ThrowUtils.throwIf(appQueryRequest == null, ErrorCode.PARAMS_ERROR); // 限制每页最多 20 个 long pageSize = appQueryRequest.getPageSize(); ThrowUtils.throwIf(pageSize > 20, ErrorCode.PARAMS_ERROR, "每页最多查询 20 个应用"); long pageNum = appQueryRequest.getPageNum(); // 只查询精选的应用 appQueryRequest.setPriority(AppConstant.GOOD_APP_PRIORITY); QueryWrapper queryWrapper = appService.getQueryWrapper(appQueryRequest); // 分页查询 Page appPage = appService.page(Page.of(pageNum, pageSize), queryWrapper); // 数据封装 Page appVOPage = new Page<>(pageNum, pageSize, appPage.getTotalRow()); List appVOList = appService.getAppVOList(appPage.getRecords()); appVOPage.setRecords(appVOList); return ResultUtils.success(appVOPage); } ``` ### 管理员删除应用 跟用户删除‍应用接口类似,但是⁡管理员可以删除任意‎应用,可以通过权限‎注解校验权限: ```java /** * 管理员删除应用 * * @param deleteRequest 删除请求 * @return 删除结果 */ @PostMapping("/admin/delete") @AuthCheck(mustRole = UserConstant.ADMIN_ROLE) public BaseResponse deleteAppByAdmin(@RequestBody DeleteRequest deleteRequest) { if (deleteRequest == null || deleteRequest.getId() <= 0) { throw new BusinessException(ErrorCode.PARAMS_ERROR); } long id = deleteRequest.getId(); // 判断是否存在 App oldApp = appService.getById(id); ThrowUtils.throwIf(oldApp == null, ErrorCode.NOT_FOUND_ERROR); boolean result = appService.removeById(id); return ResultUtils.success(result); } ``` ### 管理员更新应用 管理员可以‍更新任意应用的应用⁡名称、应用封面和优‎先级,更新优先级的‎操作其实就是精选。 管理员编辑请求类: ```java @Data public class AppAdminUpdateRequest implements Serializable { /** * id */ private Long id; /** * 应用名称 */ private String appName; /** * 应用封面 */ private String cover; /** * 优先级 */ private Integer priority; private static final long serialVersionUID = 1L; } ``` 编写接口,注意权限控制、编辑时间的设置: ```java /** * 管理员更新应用 * * @param appAdminUpdateRequest 更新请求 * @return 更新结果 */ @PostMapping("/admin/update") @AuthCheck(mustRole = UserConstant.ADMIN_ROLE) public BaseResponse updateAppByAdmin(@RequestBody AppAdminUpdateRequest appAdminUpdateRequest) { if (appAdminUpdateRequest == null || appAdminUpdateRequest.getId() == null) { throw new BusinessException(ErrorCode.PARAMS_ERROR); } long id = appAdminUpdateRequest.getId(); // 判断是否存在 App oldApp = appService.getById(id); ThrowUtils.throwIf(oldApp == null, ErrorCode.NOT_FOUND_ERROR); App app = new App(); BeanUtil.copyProperties(appAdminUpdateRequest, app); // 设置编辑时间 app.setEditTime(LocalDateTime.now()); boolean result = appService.updateById(app); ThrowUtils.throwIf(!result, ErrorCode.OPERATION_ERROR); return ResultUtils.success(true); } ``` ### 管理员分页查询应用 管理员比普‍通用户拥有更大的查⁡询范围,支持根据除‎时间外的任何字段查‎询,并且每页数量不限。 接口代码: ```java /** * 管理员分页获取应用列表 * * @param appQueryRequest 查询请求 * @return 应用列表 */ @PostMapping("/admin/list/page/vo") @AuthCheck(mustRole = UserConstant.ADMIN_ROLE) public BaseResponse> listAppVOByPageByAdmin(@RequestBody AppQueryRequest appQueryRequest) { ThrowUtils.throwIf(appQueryRequest == null, ErrorCode.PARAMS_ERROR); long pageNum = appQueryRequest.getPageNum(); long pageSize = appQueryRequest.getPageSize(); QueryWrapper queryWrapper = appService.getQueryWrapper(appQueryRequest); Page appPage = appService.page(Page.of(pageNum, pageSize), queryWrapper); // 数据封装 Page appVOPage = new Page<>(pageNum, pageSize, appPage.getTotalRow()); List appVOList = appService.getAppVOList(appPage.getRecords()); appVOPage.setRecords(appVOList); return ResultUtils.success(appVOPage); } ``` ### 管理员查看应用详情 这个接口除了权限之外,目前跟用户查看应用详情接口没有区别: ```java /** * 管理员根据 id 获取应用详情 * * @param id 应用 id * @return 应用详情 */ @GetMapping("/admin/get/vo") @AuthCheck(mustRole = UserConstant.ADMIN_ROLE) public BaseResponse getAppVOByIdByAdmin(long id) { ThrowUtils.throwIf(id <= 0, ErrorCode.PARAMS_ERROR); // 查询数据库 App app = appService.getById(id); ThrowUtils.throwIf(app == null, ErrorCode.NOT_FOUND_ERROR); // 获取封装类 return ResultUtils.success(appService.getAppVO(app)); } ``` ### 测试 接口较多,‍建议大家利用 Sw⁡agger 多多测‎试,尤其是权限。 ## 四、应用生成(后端) ### 业务流程设计 现在我们需‍要将之前实现的 A⁡I 生成功能与应用‎管理系统进行集成。 参考大厂平台,我们的整个业务流程是: 1. 用户在主页输入提示词创建应用(入库) 2. 获得应用 ID 后跳转到对话页面 3. 系统自动使用初始提示词与 AI 对话生成网站代码 由于应用的生成过程和 AI 对话是绑定的,我们可以提供一个名为 `chatToGenCode` 的应用生成接口,调用之前开发的 AI 代码生成门面完成任务,并且流式返回给前端。前端不需要区分用户是否是第一次和该应用对话,始终调用这个接口即可,需要怎么做都交给后端来判断。 一定要确保生成的文件能够与应用正确关联,因此这次生成的网站目录名称不再是之前的 `codeType_雪花算法`,而是 `codeGenType_appId`,这样就能通过 appId 查数据库获取应用信息、再根据应用信息找到对应的网站目录了。 💡 为什‍么这里不用 dep⁡loyKey 作为‎网站目录名称呢?其‎实我是刻意希望大家区分部署环境和过程。 ### 服务开发 首先需要修改 `CodeFileSaverTemplate` 的 `saveCode` 和 `buildUniqueDir` 方法,使其支持基于 appId 的目录命名: ```java /** * 模板方法:保存代码的标准流程(使用 appId) * * @param result 代码结果对象 * @param appId 应用 ID * @return 保存的目录 */ public final File saveCode(T result, Long appId) { // 1. 验证输入 validateInput(result); // 2. 构建基于 appId 的目录 String baseDirPath = buildUniqueDir(appId); // 3. 保存文件(具体实现由子类提供) saveFiles(result, baseDirPath); // 4. 返回目录文件对象 return new File(baseDirPath); } /** * 构建基于 appId 的目录路径 * * @param appId 应用 ID * @return 目录路径 */ protected final String buildUniqueDir(Long appId) { if (appId == null) { throw new BusinessException(ErrorCode.PARAMS_ERROR, "应用 ID 不能为空"); } String codeType = getCodeType().getValue(); String uniqueDirName = StrUtil.format("{}_{}", codeType, appId); String dirPath = FILE_SAVE_ROOT_DIR + File.separator + uniqueDirName; FileUtil.mkdir(dirPath); return dirPath; } ``` 修改 `CodeFileSaverExecutor` 的执行方法,补充 appId 参数: ```java /** * 模板方法:保存代码的标准流程(使用 appId) * * @param result 代码结果对象 * @param appId 应用 ID * @return 保存的目录 */ public final File saveCode(T result, Long appId) { // 1. 验证输入 validateInput(result); // 2. 构建基于 appId 的目录 String baseDirPath = buildUniqueDir(appId); // 3. 保存文件(具体实现由子类提供) saveFiles(result, baseDirPath); // 4. 返回目录文件对象 return new File(baseDirPath); } /** * 构建基于 appId 的目录路径 * * @param appId 应用 ID * @return 目录路径 */ protected final String buildUniqueDir(Long appId) { if (appId == null) { throw new BusinessException(ErrorCode.PARAMS_ERROR, "应用 ID 不能为空"); } String codeType = getCodeType().getValue(); String uniqueDirName = StrUtil.format("{}_{}", codeType, appId); String dirPath = FILE_SAVE_ROOT_DIR + File.separator + uniqueDirName; FileUtil.mkdir(dirPath); return dirPath; } ``` 修改 `AiCodeGeneratorFacade` 的所有对话方法,都添加 appId 参数: ```java /** * 模板方法:保存代码的标准流程(使用 appId) * * @param result 代码结果对象 * @param appId 应用 ID * @return 保存的目录 */ public final File saveCode(T result, Long appId) { // 1. 验证输入 validateInput(result); // 2. 构建基于 appId 的目录 String baseDirPath = buildUniqueDir(appId); // 3. 保存文件(具体实现由子类提供) saveFiles(result, baseDirPath); // 4. 返回目录文件对象 return new File(baseDirPath); } /** * 构建基于 appId 的目录路径 * * @param appId 应用 ID * @return 目录路径 */ protected final String buildUniqueDir(Long appId) { if (appId == null) { throw new BusinessException(ErrorCode.PARAMS_ERROR, "应用 ID 不能为空"); } String codeType = getCodeType().getValue(); String uniqueDirName = StrUtil.format("{}_{}", codeType, appId); String dirPath = FILE_SAVE_ROOT_DIR + File.separator + uniqueDirName; FileUtil.mkdir(dirPath); return dirPath; } ``` 同步修改单元测试,补充 appId 参数: ```java /** * 模板方法:保存代码的标准流程(使用 appId) * * @param result 代码结果对象 * @param appId 应用 ID * @return 保存的目录 */ public final File saveCode(T result, Long appId) { // 1. 验证输入 validateInput(result); // 2. 构建基于 appId 的目录 String baseDirPath = buildUniqueDir(appId); // 3. 保存文件(具体实现由子类提供) saveFiles(result, baseDirPath); // 4. 返回目录文件对象 return new File(baseDirPath); } /** * 构建基于 appId 的目录路径 * * @param appId 应用 ID * @return 目录路径 */ protected final String buildUniqueDir(Long appId) { if (appId == null) { throw new BusinessException(ErrorCode.PARAMS_ERROR, "应用 ID 不能为空"); } String codeType = getCodeType().getValue(); String uniqueDirName = StrUtil.format("{}_{}", codeType, appId); String dirPath = FILE_SAVE_ROOT_DIR + File.separator + uniqueDirName; FileUtil.mkdir(dirPath); return dirPath; } ``` 在 App‍Service 中⁡编写 chatTo‎GenCode 方‎法,调用门面生成代码: ```java /** * 模板方法:保存代码的标准流程(使用 appId) * * @param result 代码结果对象 * @param appId 应用 ID * @return 保存的目录 */ public final File saveCode(T result, Long appId) { // 1. 验证输入 validateInput(result); // 2. 构建基于 appId 的目录 String baseDirPath = buildUniqueDir(appId); // 3. 保存文件(具体实现由子类提供) saveFiles(result, baseDirPath); // 4. 返回目录文件对象 return new File(baseDirPath); } /** * 构建基于 appId 的目录路径 * * @param appId 应用 ID * @return 目录路径 */ protected final String buildUniqueDir(Long appId) { if (appId == null) { throw new BusinessException(ErrorCode.PARAMS_ERROR, "应用 ID 不能为空"); } String codeType = getCodeType().getValue(); String uniqueDirName = StrUtil.format("{}_{}", codeType, appId); String dirPath = FILE_SAVE_ROOT_DIR + File.separator + uniqueDirName; FileUtil.mkdir(dirPath); return dirPath; } ``` ### SSE 流式接口开发 AppCon‍troller 新增接口⁡,注意要声明为 SSE ‎流式返回,使用 get ‎请求便于前端使用 EventSource 对接: ```java /** * 模板方法:保存代码的标准流程(使用 appId) * * @param result 代码结果对象 * @param appId 应用 ID * @return 保存的目录 */ public final File saveCode(T result, Long appId) { // 1. 验证输入 validateInput(result); // 2. 构建基于 appId 的目录 String baseDirPath = buildUniqueDir(appId); // 3. 保存文件(具体实现由子类提供) saveFiles(result, baseDirPath); // 4. 返回目录文件对象 return new File(baseDirPath); } /** * 构建基于 appId 的目录路径 * * @param appId 应用 ID * @return 目录路径 */ protected final String buildUniqueDir(Long appId) { if (appId == null) { throw new BusinessException(ErrorCode.PARAMS_ERROR, "应用 ID 不能为空"); } String codeType = getCodeType().getValue(); String uniqueDirName = StrUtil.format("{}_{}", codeType, appId); String dirPath = FILE_SAVE_ROOT_DIR + File.separator + uniqueDirName; FileUtil.mkdir(dirPath); return dirPath; } ``` ### 接口测试 测试下效果,创建应用,得到 appId: ![[image-6adb7269.png]] 接下来要调‍用生成应用接口。为⁡了更好地看到流式输‎出,用 CURL ‎等测试工具而不是 Swagger: ```java /** * 模板方法:保存代码的标准流程(使用 appId) * * @param result 代码结果对象 * @param appId 应用 ID * @return 保存的目录 */ public final File saveCode(T result, Long appId) { // 1. 验证输入 validateInput(result); // 2. 构建基于 appId 的目录 String baseDirPath = buildUniqueDir(appId); // 3. 保存文件(具体实现由子类提供) saveFiles(result, baseDirPath); // 4. 返回目录文件对象 return new File(baseDirPath); } /** * 构建基于 appId 的目录路径 * * @param appId 应用 ID * @return 目录路径 */ protected final String buildUniqueDir(Long appId) { if (appId == null) { throw new BusinessException(ErrorCode.PARAMS_ERROR, "应用 ID 不能为空"); } String codeType = getCodeType().getValue(); String uniqueDirName = StrUtil.format("{}_{}", codeType, appId); String dirPath = FILE_SAVE_ROOT_DIR + File.separator + uniqueDirName; FileUtil.mkdir(dirPath); return dirPath; } ``` 输出结果符合预期: ![[image-cb85e6c6.png]] ### SSE 流式接口优化 目前虽然能‍流式输出了,但其实⁡获取到的数据是有问‎题的!我们还要对 ‎SSE 接口进行优化,解决 2 个问题。 ### 1、解决空格丢失问题 前端使用 ‍EventSour⁡ce 对接目前的接‎口时,会出现空格丢‎失问题。 ![[image-c925f4be.png]] 解决方案是‍在后端封装数据,可⁡以参考 DeepS‎eek 的做法,将‎原本的返回值封装到 JSON 中: ![[image-7c4c7b52.png]] 而美团 NoCode 的做法更高级一些,对内容进行了加密: 按照封装的思路,我们可以编写下列代码,将 Flux 额外封装成 ServerSentEvent,把原始数据放到 JSON 的 `d` 字段内: ```java @GetMapping(value = "/chat/gen/code", produces = MediaType.TEXT_EVENT_STREAM_VALUE) public Flux> chatToGenCode(@RequestParam Long appId, @RequestParam String message, HttpServletRequest request) { // 参数校验 ThrowUtils.throwIf(appId == null || appId <= 0, ErrorCode.PARAMS_ERROR, "应用ID无效"); ThrowUtils.throwIf(StrUtil.isBlank(message), ErrorCode.PARAMS_ERROR, "用户消息不能为空"); // 获取当前登录用户 User loginUser = userService.getLoginUser(request); // 调用服务生成代码(流式) Flux contentFlux = appService.chatToGenCode(appId, message, loginUser); // 转换为 ServerSentEvent 格式 return contentFlux .map(chunk -> { // 将内容包装成JSON对象 Map wrapper = Map.of("d", chunk); String jsonData = JSONUtil.toJsonStr(wrapper); return ServerSentEvent.builder() .data(jsonData) .build(); }); } ``` 再次测试,空格也会正常输出了: ![[image-fbc2002a.png]] 💡 除了‍这种方式外,也可以⁡在后端对空格进行转‎义,前端再转回来。 ### 2、主动告诉前端生成完成 在 SSE 中,当服务器关闭连接时,会触发客户端的 `onclose` 事件,这是前端判断流结束的标准方式。但是,`onclose`事件会在连接正常结束(服务器主动关闭)和异常中断(如网络问题)时都触发,前端就很难区分到底后端是正常响应了所有数据、还是异常中断了。 因此,我们最好在后端添加一个明确的 `done` 事件,这样可以更清晰地区分流的正常结束和异常中断。 修改接口代码,额外追加结束事件: ```java @GetMapping(value = "/chat/gen/code", produces = MediaType.TEXT_EVENT_STREAM_VALUE) public Flux> chatToGenCode(@RequestParam Long appId, @RequestParam String message, HttpServletRequest request) { // 参数校验 ThrowUtils.throwIf(appId == null || appId <= 0, ErrorCode.PARAMS_ERROR, "应用ID无效"); ThrowUtils.throwIf(StrUtil.isBlank(message), ErrorCode.PARAMS_ERROR, "用户消息不能为空"); // 获取当前登录用户 User loginUser = userService.getLoginUser(request); // 调用服务生成代码(流式) Flux contentFlux = appService.chatToGenCode(appId, message, loginUser); // 转换为 ServerSentEvent 格式 return contentFlux .map(chunk -> { // 将内容包装成JSON对象 Map wrapper = Map.of("d", chunk); String jsonData = JSONUtil.toJsonStr(wrapper); return ServerSentEvent.builder() .data(jsonData) .build(); }) .concatWith(Mono.just( // 发送结束事件 ServerSentEvent.builder() .event("done") .data("") .build() )); } ``` 再次测试,效果如图: ![[image-c4e4b0a4.png]] 之后前端需要从 data 块中取出数据,并通过 `event == done` 判断事件结束。 ## 五、应用部署(后端) 之前我们是‍在本地手动双击打开⁡ HTML 文件来‎查看网站生成效果的‎,但现在平台化了,肯定不能让用户也这么干。 ### 部署方案 部署的整体思路是:把本地生成的文件同步到一个 **Web 服务器** 上。可以是同一个服务器的不同目录,也可以是不同服务器,但显然前者成本更低。 ![[image-3d8fc8e2.png]] 我们有几种部署方案可以选择: ### 1、**使用 Serve 工具** 这是最简单的方案,通过 Node.js 的 `serve` 包可以快速启动一个 web 服务器,为指定目录提供 Web 访问服务。 先安装 serve 工具: ```bash npm i -g serve ``` 假设 code\_output 目录就是要部署的文件目录,只需要在这个目录内运行 `serve` 命令: ![[image-48e45fa8.png]] 就能查看到目录内的网站了: ![[image-03fc7be6.png]] 访问对应的路径,直接就能访问到对应的网站: ![[image-b18846eb.png]] 使用时,只需提‍前在服务器上启动 serve⁡ 服务器,就能为特定部署目录‎提供 web 服务(比如 d‎eployed),然后部署时将代码文件移动到这个目录下即可。 这种方案的优点是配置简单;缺点是依赖 Node.js 环境,需要独立启动 Web 服务进程,而且性能相对较低。 💡 当然,你也‍可以让 serve 服务器跟随⁡ Spring Boot 项目‎启动或关闭,示例代码如下:  ‎ 1)使用命令行运行 serve: ```java @Service public class ServeDeployService { private static final String CODE_BASE_DIR = "/tmp/deploy"; private static final int SERVE_PORT = 3000; private static Process serveProcess; /** * 启动 Serve 服务 */ public void startServeService() { try { if (serveProcess == null || !serveProcess.isAlive()) { ProcessBuilder pb = new ProcessBuilder( "npx", "serve", CODE_BASE_DIR, "-p", String.valueOf(SERVE_PORT) ); pb.redirectErrorStream(true); serveProcess = pb.start(); System.out.println("Serve service started on port " + SERVE_PORT); } } catch (Exception e) { throw new RuntimeException("Failed to start serve service", e); } } /** * 关闭 Serve 服务 */ public void stopServeService() { if (serveProcess != null && serveProcess.isAlive()) { serveProcess.destroy(); try { serveProcess.waitFor(5, TimeUnit.SECONDS); System.out.println("Serve service stopped"); } catch (InterruptedException e) { serveProcess.destroyForcibly(); System.out.println("Serve service force stopped"); } } } } ``` 2)控制 serve 进程的生命周期: ```java @Component public class ServeLifecycleManager { @Autowired private ServeDeployService serveDeployService; /** * Spring Boot 启动完成后启动 Serve 服务 */ @EventListener(ApplicationReadyEvent.class) public void onApplicationReady() { serveDeployService.startServeService(); } /** * Spring Boot 关闭时停止 Serve 服务 */ @PreDestroy public void onApplicationShutdown() { System.out.println("Shutting down Serve service..."); serveDeployService.stopServeService(); } } ``` ### 2、通过 Spring Boot 接口 我们可以直‍接在后端项目中实现⁡一个静态资源服务接‎口,输入部署路径,‎返回相应的文件: ```java @RestController @RequestMapping("/static") public class StaticResourceController { // 应用生成根目录(用于浏览) private static final String PREVIEW_ROOT_DIR = System.getProperty("user.dir") + "/tmp/code_output"; /** * 提供静态资源访问,支持目录重定向 * 访问格式: */ @GetMapping("/{deployKey}/**") public ResponseEntity serveStaticResource( @PathVariable String deployKey, HttpServletRequest request) { try { // 获取资源路径 String resourcePath = (String) request.getAttribute(HandlerMapping.PATH_WITHIN_HANDLER_MAPPING_ATTRIBUTE); resourcePath = resourcePath.substring(("/static/" + deployKey).length()); // 如果是目录访问(不带斜杠),重定向到带斜杠的URL if (resourcePath.isEmpty()) { HttpHeaders headers = new HttpHeaders(); headers.add("Location", request.getRequestURI() + "/"); return new ResponseEntity<>(headers, HttpStatus.MOVED_PERMANENTLY); } // 默认返回 index.html if (resourcePath.equals("/")) { resourcePath = "/index.html"; } // 构建文件路径 String filePath = PREVIEW_ROOT_DIR + "/" + deployKey + resourcePath; File file = new File(filePath); // 检查文件是否存在 if (!file.exists()) { return ResponseEntity.notFound().build(); } // 返回文件资源 Resource resource = new FileSystemResource(file); return ResponseEntity.ok() .header("Content-Type", getContentTypeWithCharset(filePath)) .body(resource); } catch (Exception e) { return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build(); } } /** * 根据文件扩展名返回带字符编码的 Content-Type */ private String getContentTypeWithCharset(String filePath) { if (filePath.endsWith(".html")) return "text/html; charset=UTF-8"; if (filePath.endsWith(".css")) return "text/css; charset=UTF-8"; if (filePath.endsWith(".js")) return "application/javascript; charset=UTF-8"; if (filePath.endsWith(".png")) return "image/png"; if (filePath.endsWith(".jpg")) return "image/jpeg"; return "application/octet-stream"; } } ``` 测试访问,效果如图: ![[image-aa506689.png]]![[image-c22ab33d.png]] 这种方案的‍优点是无需额外进程⁡,非常方便;缺点是‎功能相对简单,性能‎也不如专业的 Web 服务器。 ### 3、使用 Nginx 映射 Nginx 是专业的 Web 服务器,性能优异,功能丰富。**因此这是最推荐的生产环境方案。** 1)首先到 [Nginx 官网](https://nginx.org/en/download.html) 下载 Nginx,各个操作系统的安装包都有。 下载好 Nginx 后,找到 Nginx 配置文件 `nginx.conf`,Windows 系统直接到安装目录下找,Mac / Linux 系统可以通过命令查看 Nginx 配置文件位置: ```text nginx -t ``` 如图: ![[image-cbd2b616.png]] 2)修改 ‍Nginx 配置,⁡http 块中添加‎ server 块‎,配置 root 为项目部署根目录: ```nginx # 静态资源服务器 - 80 端口 server { listen 80; server_name localhost; charset utf-8; charset_types text/css application/javascript text/plain text/xml application/json; # 项目部署根目录 root /Users/yupi/Code/yu-ai-code-mother/tmp/code_deploy; # 处理所有请求 location ~ ^/([^/]+)/(.*)$ { try_files /$1/$2 /$1/index.html =404; } } ``` 注意,Windows 系统的路径斜杠要相反,比如 `C:/code/yu-ai-code-mother_live/tmp/code_deploy` 上述配置中使用了 `try_files` 指令,能够按顺序尝试多个文件路径,从而更灵活地处理文件访问。举个例子,当访问 `/app/style.css` 时,会先尝试找到 `/app/style.css` 文件,如果不存在则返回 `/app/index.html`,最后才返回 404 错误,这样能够适配后续我们要生成的 Vue 单页面应用。 💡 try\_files 指令的具体解释: - /$1/$2:第一个尝试的路径 - /$1/index.html:第二个尝试的路径 - =404:如果都找不到,返回 404 错误 举些例子: | 访问URL | $1 | $2 | 尝试路径1 | 尝试路径2 | | --- | --- | --- | --- | --- | | /app/ | app | ““ | /app/ | /app/index.html | | /app/style.css | app | style.css | /app/style.css | /app/index.html | | /app/js/main.js | app | js/main.js | /app/js/main.js | /app/index.html | 3)启动 Nginx,或者输入命令来重载配置: ```text nginx -s reload ``` 然后直接访问 80 端口,就能看到网站了: ![[image-3ee012dd.png]] 这种方案的‍性能最佳,最适合生⁡产环境;缺点是需要‎额外引入 Nginx‎ 组件。 💡 其实还可‍以用 COS 对象存储的静态⁡网站访问能力,同时实现存储 ‎+ 访问;缺点是需要自定义域‎名,考虑到很多朋友没有域名,所以教程中不选择这种方案。 ### 最终方案 基于以上分析‍,我们最终选择了混合方案⁡:使用 Spring B‎oot 接口实现 AI ‎生成的网页预览,使用 Nginx 提供网站部署服务。 ### 开发 部署接口接受 appId 作为请求参数,返回可访问的 URL 地址 `${部署域名}/{deployKey}`。 部署流程如下: 1. 参数校验:比如是否存在 App、用户是否有权限部署该应用(仅本人可以部署) 2. 生成 deployKey:之前设计库表时已经提到了 deployKey 的生成逻辑(6 位大小写字母 + 数字),还要注意不能跟已有的 key 重复;此外,每个 app 只生成一次 deployKey,已有则不生成。 3. 部署操作:本质是将 `code_output` 目录下的临时文件复制到 `code_deploy` 目录下,为了简化访问地址,直接将 deployKey 作为文件名。 1)首先在 `AppConstant` 中定义常量: ```java /** * 应用生成目录 */ String CODE_OUTPUT_ROOT_DIR = System.getProperty("user.dir") + "/tmp/code_output"; /** * 应用部署目录 */ String CODE_DEPLOY_ROOT_DIR = System.getProperty("user.dir") + "/tmp/code_deploy"; /** * 应用部署域名 */ String CODE_DEPLOY_HOST = ""; ``` CodeF‍ileSaverT⁡emplate 中‎使用文件保存根目录‎常量(用于保存生成的文件): ```java // 文件保存根目录 protected static final String FILE_SAVE_ROOT_DIR = AppConstant.CODE_OUTPUT_ROOT_DIR; ``` Stati‍cResource⁡Controlle‎r 中使用文件保存‎根目录常量,因为要在生成时就预览效果: ```java // 应用生成根目录(用于浏览) private static final String PREVIEW_ROOT_DIR = AppConstant.CODE_OUTPUT_ROOT_DIR; ``` 2)编写部署请求类: ```java @Data public class AppDeployRequest implements Serializable { /** * 应用 id */ private Long appId; private static final long serialVersionUID = 1L; } ``` 3)基于上‍述流程,在 App⁡Service 中‎编写部署服务的代码‎: ```java @Override public String deployApp(Long appId, User loginUser) { // 1. 参数校验 ThrowUtils.throwIf(appId == null || appId <= 0, ErrorCode.PARAMS_ERROR, "应用 ID 不能为空"); ThrowUtils.throwIf(loginUser == null, ErrorCode.NOT_LOGIN_ERROR, "用户未登录"); // 2. 查询应用信息 App app = this.getById(appId); ThrowUtils.throwIf(app == null, ErrorCode.NOT_FOUND_ERROR, "应用不存在"); // 3. 验证用户是否有权限部署该应用,仅本人可以部署 if (!app.getUserId().equals(loginUser.getId())) { throw new BusinessException(ErrorCode.NO_AUTH_ERROR, "无权限部署该应用"); } // 4. 检查是否已有 deployKey String deployKey = app.getDeployKey(); // 没有则生成 6 位 deployKey(大小写字母 + 数字) if (StrUtil.isBlank(deployKey)) { deployKey = RandomUtil.randomString(6); } // 5. 获取代码生成类型,构建源目录路径 String codeGenType = app.getCodeGenType(); String sourceDirName = codeGenType + "_" + appId; String sourceDirPath = AppConstant.CODE_OUTPUT_ROOT_DIR + File.separator + sourceDirName; // 6. 检查源目录是否存在 File sourceDir = new File(sourceDirPath); if (!sourceDir.exists() || !sourceDir.isDirectory()) { throw new BusinessException(ErrorCode.SYSTEM_ERROR, "应用代码不存在,请先生成代码"); } // 7. 复制文件到部署目录 String deployDirPath = AppConstant.CODE_DEPLOY_ROOT_DIR + File.separator + deployKey; try { FileUtil.copyContent(sourceDir, new File(deployDirPath), true); } catch (Exception e) { throw new BusinessException(ErrorCode.SYSTEM_ERROR, "部署失败:" + e.getMessage()); } // 8. 更新应用的 deployKey 和部署时间 App updateApp = new App(); updateApp.setId(appId); updateApp.setDeployKey(deployKey); updateApp.setDeployedTime(LocalDateTime.now()); boolean updateResult = this.updateById(updateApp); ThrowUtils.throwIf(!updateResult, ErrorCode.OPERATION_ERROR, "更新应用部署信息失败"); // 9. 返回可访问的 URL return String.format("%s/%s/", AppConstant.CODE_DEPLOY_HOST, deployKey); } ``` 这个实现的优点在于支‍持重复部署。如果应用已经有 deplo⁡yKey,就直接使用现有的;如果没有,‎就生成一个新的。这样既保证了 URL ‎的稳定性,又支持了代码的更新。缺点是不支持区分同一个应用多次部署的版本。 💡 如果系统‍用量极大,为了减少 depl⁡oyKey 重复概率,可以添‎加数据库查重 + 重试。但我‎觉得概率太低了,用户自己重试的成本也不高,就不加了。 4)编写接口: ```java /** * 应用部署 * * @param appDeployRequest 部署请求 * @param request 请求 * @return 部署 URL */ @PostMapping("/deploy") public BaseResponse deployApp(@RequestBody AppDeployRequest appDeployRequest, HttpServletRequest request) { ThrowUtils.throwIf(appDeployRequest == null, ErrorCode.PARAMS_ERROR); Long appId = appDeployRequest.getAppId(); ThrowUtils.throwIf(appId == null || appId <= 0, ErrorCode.PARAMS_ERROR, "应用 ID 不能为空"); // 获取当前登录用户 User loginUser = userService.getLoginUser(request); // 调用服务部署应用 String deployUrl = appService.deployApp(appId, loginUser); return ResultUtils.success(deployUrl); } ``` ### 测试 找一个生成过网站的 appId 来测试部署,验证成功: ![[image-5d2ae6ca.png]] 可以看到部署文件: ![[image-f7a84749.png]] 数据库内的信息也是正确的: ![[image-29d42ebe.png]] 至此,应用模块的后端开发完成。 ## 六、前端开发 后端开发完成后,记得先执行 `openapi` 命令,根据接口生成前端请求和数据模型代码。 ### 基础代码实现 - Vibe Coding 由于前端开‍发涉及多个页面和复杂⁡的交互逻辑,我们可以‎充分利用 AI 来提高开‎发效率。关键是要提供足够详细的提示词。 建议对 A‍I 不熟、前端经验较⁡少的同学分成多个页面‎依次去生成。我这里有‎点儿自信,就交给 AI 一把梭,提示词如下: ```text 你是一位专业的前端开发,帮我根据原型图、页面介绍、需求介绍、业务流程和后端接口信息,参考项目已有的代码风格,生成符合要求的完整代码。 ## 页面介绍 1)主页(参考原型图 1、2):从上到下,分别是网站标题、用户提示词输入框、我的应用分页列表、精选应用分页列表 2)应用生成对话页(参考原型图 3): - 顶部栏的左侧是应用名称,右侧是部署按钮,顶部栏下方是核心内容区域 - 核心内容区域: - 左侧是对话区域,左侧自然而下分别是消息区域(用户消息在右、AI 消息在左)、用户消息输入框 - 右侧是网页展示区域,当网站文件生成完成(流式接口全部返回后)展示 3)应用管理页:仅管理员可进入、可以在菜单项上看到,管理页样式和用户管理页面相同。 操作栏提供按钮: - 编辑:新开页面跳转到应用信息修改页进行编辑 - 删除 - 精选:设置应用优先级为 99,本质上也是编辑 4)应用信息修改页:用户和管理员都可以进入,但普通用户只能编辑自己的应用 ## 需求介绍 用户可以在本网站通过和 AI 对话创建网站应用、查看生成的网站应用效果、部署应用、管理个人应用、查看精选应用;管理员可以对整个网站的任意应用进行管理。 具体需求如下: - 【用户】输入用户提示词来创建应用 - 【用户】修改自己的应用信息(目前只支持修改应用名称) - 【用户】删除自己的应用 - 【用户】查看应用详情 - 【用户】通过和 AI 对话生成网站应用,并查看效果 - 【用户】部署应用 - 【用户】分页查询自己的应用列表(支持根据名称查询,每页最多 20 个) - 【用户】分页查询精选的应用列表(支持根据名称查询,每页最多 20 个) - 【管理员】删除任意应用 - 【管理员】更新任意应用信息(支持更新应用名称、应用封面、优先级) - 【管理员】分页查询应用列表(支持根据除时间外的任何字段查询,每页数量不限) - 【管理员】查看任意应用详情 ## 业务流程 1)用户在主页输入框输入提示词后,调用创建应用接口得到应用 id,然后跳转到对话页面;自动将应用的初始提示词作为消息发送给 AI,并且通过 SSE 对话接口实时输出 AI 的回复;当 AI 回复完后,自动在右侧展示生成的网站效果。(本地域名为 2)用户可以在对话页面部署网站,调用后端部署接口,得到可访问的 URL 地址。 3)其他业务参考需求介绍,调用对应的后端接口实现 ## 后端接口 已经在 @api 目录下生成了后端请求代码和数据类型信息,详细的接口文档我也作为文件提供给了你 @接口文档.md。 ``` 需要关联的原型图 1 - 主页上方(参考大厂页面设计): ![[image-16ba57a3.png]] 原型图 2 - 主页下方(参考大厂页面设计): ![[image-3c6c8637.png]] 原型图 3 - 对话页面(参考大厂页面设计): ![[image-b907e93d.png]] 还要关联接口文档,可以直接在 Swagger 里导出: ![[image-ba699dbb.png]] 注意,一定‍要关联上相关内容!⁡建议开启新对话执行‎,减少历史干扰因素‎ ![[image-6e784837.png]] 等待几分钟,AI 不仅生成了代码、还生成了 [README.md](http://README.md) 文档,建议先看这个文档来了解 AI 都干了哪些事情。 ![[image-0b9ec1ef.png]] 通过提供详‍细的需求、原型图和⁡文档,AI 能够生‎成质量很高的前端代‎码,大大提升了开发效率。 注意,由于‍ AI 生成的代码⁡每次都不同,所以接‎下来我们要学习的是‎通用的功能验收流程、以及常见问题的处理方法。 大家即使无法生成可用的代码也没关系,可以直接从 [本项目的源码](https://github.com/liyupi/yu-ai-code-mother) 获取。 - 📎AI 生成前端代码.zip - 📎yu-ai-code-mother-frontend(最终版).rar [👆 资源获取文档](https://yuyuanweb.yuque.com/org-wiki-yuyuanweb-zvq1bg/webrf4/kquwk9bzruniczdg?singleDoc#):密码 tpkb > 第一次需登录语雀账号,显示无权限,再访问 👆 链接和密码 ### 核心功能验收 我们需要对照着需求文档验收功能,这里有几个建议: 1. 一定是先确保功能可用,再优化样式和细节。 2. 先验收最难实现的功能,或者跑通核心业务流程。只要复杂的问题 AI 能够解决,其他的都不是事儿~ 3. 建议每次 AI 生成完成后先提交下代码,之后每修复一个问题、或者进行一次改动就再提交一次。确保 AI 生成的代码在可控范围内,出了问题可以及时回滚。 下面我们就先‍验收核心流程:用户输入提⁡示词来创建应用 => 通‎过和 AI 对话生成网站‎应用 => 生成成功后查看效果,再验收其他功能。 ### 1、用户输入提示词来创建应用 ### 常见问题 - 获取应用信息失败 通过 F1‍2 网络控制台发现⁡是前端请求应用信息‎时,传递的 id ‎参数丢失了精度: ![[image-c4062bd5.png]] 解决任何问‍题时,一定要先搜集⁡信息;懂前端的同学‎最好自己也看下代码‎,再把自己的思路发给 AI 解决。 比如我本能地想到,‍这是由于精度丢失导致的,JavaSc⁡ript 的 Number 类型无法‎精确表示大整数。所以才能写出下面这种‎提示词,直接告诉 AI 如何解决,而不是依赖 AI 的随机性来解决。 ```text 创建应用成功后,获取应用信息失败,由于 id 丢失了精度(后端返回的是 string)。 注意,无论如何,整个项目都不应该将 appId 转为 Number,这样会丢失精度!如果有 ts 报错,用其他的方式修复 ``` 我的这个提示词触‍发了 Cursor 的自动记忆功⁡能,它会自动将一些规则保存下来,‎建议设置下,之后对话就会一直生效‎,否则可能 AI 经常会把 appId 再转为 Number。 ![[image-0030203a.png]] 这个问题的‍修复方法是:前端始⁡终将 ID 作为字‎符串处理,不要将字‎符串转为数字。 ```properties // 原本 appId.value = Number(id) // 改为 appId.value = id ``` ### 2、AI 对话生成应用,并查看效果 ### 常见问题 - SSE 接口调用错误 进到对话页‍面,页面能正常显示⁡,并且页面的路径正‎确,但是调用应用生‎成接口时报错了。 查看 F12 网络控制台,发现是 404 错误: ![[image-fd3aae71.png]] 404 是因为找不到接口,显然调用路径的端口错误(不应该是 5173)。理想情况下是从 `request.ts` 中配置的 Axios 的 baseUrl 中获取到调用域名,保证整个项目中请求路径前缀一致。 尝试让 AI 帮忙解决,这里有几个写提示词的小技巧: 1. 多提供一些有效信息,比如后端的接口代码 2. 尽量减少代码改动,防止误修改 3. 避免改动利用工具自动生成的代码,比如 api 目录,否则之后再次生成时还会被覆盖 我写的提示词如下: ```java 调用 AI 对话接口报错 404, 我看了代码,发现 generateCode 你没有使用我生成的接口代码调用,请将这段逻辑改为 SSE 流式返回,并且后端地址是 注意,禁止改动 api 目录下的代码,并且你必须只专注于当前问题的修复,尽量减少改动范围! 后端接口代码如下: ```java @GetMapping(value = "/chat/gen/code", produces = MediaType.TEXT_EVENT_STREAM_VALUE) public Flux> chatToGenCode(@RequestParam Long appId, @RequestParam String message, HttpServletRequest request) { // 参数校验 ThrowUtils.throwIf(appId == null || appId <= 0, ErrorCode.PARAMS_ERROR, "应用ID无效"); ThrowUtils.throwIf(StrUtil.isBlank(message), ErrorCode.PARAMS_ERROR, "用户消息不能为空"); // 获取当前登录用户 User loginUser = userService.getLoginUser(request); // 调用服务生成代码(流式) Flux contentFlux = appService.chatToGenCode(appId, message, loginUser); // 转换为 ServerSentEvent 格式 return contentFlux .map(chunk -> { // 将内容包装成JSON对象 Map wrapper = Map.of("d", chunk); String jsonData = JSONUtil.toJsonStr(wrapper); return ServerSentEvent.builder() .data(jsonData) .build(); }) .concatWith(Mono.just( // 发送结束事件 ServerSentEvent.builder() .event("done") .data("") .build() )); } ``` 修复之后,页面成功流式输出了 AI 的回复内容,运行效果如图: ![[image-fd7e4f33.png]] 💡 测试时最好在提示词中指定 “生成的网站代码量 < 50 行”,提升效率。 ### 常见问题 - 代码生成完成后没有展示 AI 回复结束之后,右侧并没有展示生成的网站: ![[image-035d1b48.png]] 首先我们要定位问题的范围:是后端没生成网站文件?还是前端没有展示网站? 可以先自主查看后端有没有对应生成的完整网站目录。我这里看到是有的,所以向 AI 提问时又可以提供一个有效信息: ```text 代码生成完成‍后没有触发展示,但是后端⁡有对应生成的完整网站‎目录。 请仔细检查在 SSE‎ 流式调用结果全部获取后,是否触发了网站展示。 ``` AI 顺利修复完成,可以正常加载网站: ![[image-e14247a6.png]] 目前已经支持了多次对话,不过还没有对话记忆,相当于每次都是新生成了一个网站: ![[image-57a1942c.png]] ### 值得学习的代码 - SSE 调用 我们需要了解一下前端是如何调用后端 SSE 接口并流式输出的,核心是通过 EventSource。 首先我们来看看如何建立 SSE 连接。在生成代码的函数中,我们需要构建正确的请求 URL 并创建 EventSource 实例: ```javascript // 生成代码 - 使用 EventSource 处理流式响应 const generateCode = async (userMessage: string, aiMessageIndex: number) => { let eventSource: EventSource | null = null let streamCompleted = false try { // 获取 axios 配置的 baseURL const baseURL = request.defaults.baseURL || API_BASE_URL // 构建URL参数 const params = new URLSearchParams({ appId: appId.value || '', message: userMessage, }) const url = `${baseURL}/app/chat/gen/code?${params}` // 创建 EventSource 连接 eventSource = new EventSource(url, { withCredentials: true, }) ``` 这里的关键是要正确构建 URL 参数,并且设置 `withCredentials: true` 来确保认证信息能够正确传递。`streamCompleted` 标志用于防止重复处理事件,应对网络异常。 接下来是处理服务器发送的消息数据。每当服务器发送新的数据块时,`onmessage` 事件就会被触发: ```javascript let fullContent = '' // 处理接收到的消息 eventSource.onmessage = function (event) { if (streamCompleted) return try { // 解析JSON包装的数据 const parsed = JSON.parse(event.data) const content = parsed.d // 拼接内容 if (content !== undefined && content !== null) { fullContent += content messages.value[aiMessageIndex].content = fullContent messages.value[aiMessageIndex].loading = false scrollToBottom() } } catch (error) { console.error('解析消息失败:', error) handleError(error, aiMessageIndex) } } ``` 这段代码的核心思路是 **逐步拼接收到的内容**。每次收到新的数据块后,都会将其追加到 `fullContent` 中,然后更新界面上对应消息的内容。同时调用 `scrollToBottom()` 确保用户能看到最新的内容,这样就实现了打字机效果。 当 AI 完成代码生成后,服务器会发送一个特殊的 `done` 事件来通知前端: ```java // 处理done事件 eventSource.addEventListener('done', function () { if (streamCompleted) return streamCompleted = true isGenerating.value = false eventSource?.close() // 延迟更新预览,确保后端已完成处理 setTimeout(async () => { await fetchAppInfo() updatePreview() }, 1000) }) ``` 收到 `done` 事件后,我们需要做几件事:首先标记流式传输已完成,然后关闭 EventSource 连接释放资源。这里使用了 1 秒的延迟来确保后端已经完全处理完毕,然后再获取最新的应用信息并更新预览。 错误处理也是 SSE 连接中的重要环节: ```java // 处理done事件 eventSource.addEventListener('done', function () { if (streamCompleted) return streamCompleted = true isGenerating.value = false eventSource?.close() // 延迟更新预览,确保后端已完成处理 setTimeout(async () => { await fetchAppInfo() updatePreview() }, 1000) }) ``` 这里需要区分正常的连接关闭和真正的错误。当 EventSource 的状态是 `CONNECTING` 时,通常表示连接正常关闭,我们按照正常流程处理即可。其他情况则认为是真正的错误,需要调用错误处理函数。 最后是统一的错误处理函数: ```javascript // 错误处理函数 const handleError = (error: unknown, aiMessageIndex: number) => { console.error('生成代码失败:', error) messages.value[aiMessageIndex].content = '抱歉,生成过程中出现了错误,请重试。' messages.value[aiMessageIndex].loading = false message.error('生成失败,请重试') isGenerating.value = false } ``` 错误处理函数负责清理状态、显示友好的错误信息给用户,并重置生成状态。这样即使出现异常,用户界面也能保持一致的状态。 从建立连接、处理数据、到错误处理和资源清理,每个环节 AI 都考虑得比较周全,不愧是 AI 生成的代码,还是很严谨的。 ### 3、用户分页查询自己的应用列表 下面验收主页,可以通过修改数据库,给自己创建的应用统一设置封面,比如随机占位图 [https://picsum.photos/320/180。](https://picsum.photos/320/180%E3%80%82) 效果如图,AI 直接完美生成了: ![[image-11d14e09.png]] ### 4、分页查询精选的应用列表 可以通过修改数据库,将一些应用的优先级修改为 99(表示精选)。 ### 常见问题 - 排序错误 一般来说,我们要按照时间降序对精选应用进行排序。这个问题可以直接自己修复,找到调用获取精选应用接口的位置,设置排序参数即可: ```javascript // 加载精选应用 const loadFeaturedApps = async () => { try { const res = await listGoodAppVoByPage({ pageNum: featuredAppsPage.current, pageSize: featuredAppsPage.pageSize, sortField: 'createTime', sortOrder: 'desc', }) if (res.data.code === 0 && res.data.data) { featuredApps.value = res.data.data.records || [] featuredAppsPage.total = res.data.data.totalRow || 0 } } catch (error) { console.error('加载精选应用失败:', error) } } ``` 效果如图: ![[image-32d02571.png]] ### 5、查看应用详情 点击应用卡片后,可以查看应用详情。参考大厂平台、同样也是为了方便,应用详情页面可以复用应用对话页面。 ### 常见问题 - 应用卡片补充查看作品按钮 由于之前生成时没有提供足够的原型图,AI 应该不会生成 “查看作品” 按钮。 ![[image-8c9e0240.png]] 无论是我的作品还是精选页面,所有应用卡片展示按钮的逻辑一致。鼠标悬浮时,展示 2 个按钮: - 查看对话:始终显示,点击后跳转到应用对话页面 - 查看作品:如果查看的应用有 deployKey,会展示查看作品按钮,点击后新页面打开部署地址。部署地址目前为 localhost/{deployKey}。注意,部署地址和生成网站的浏览地址不同,不要修改错了! ### 常见问题 - 查看详情时自动发送了消息 由于现在并没有记录应用的生成状态,无法在对话详情页判断应用是否生成过,所以每次进入对话详情页时都会自动发送初始提示词。 解决这个问题的方法很简单,区分浏览和生成即可。可以在跳转时增加一个 url 查询参数(比如 `?view=1`),如果有这个参数,则不会默认发送一条消息。 不过这个逻辑现在做了也没什么用,因为我们并没有保存对话,后续章节会实现。 此外,还要补充权限校验。如果查看的不是自己的作品,应用禁用编辑框;并且鼠标放到编辑框上时,给出提示 “无法在别人的作品下对话哦~” 把我们的思考作为提示词发送给 AI: ```text 处理以下逻辑: 1)针对应‍用卡片补充查看作品⁡按钮,我的作品和精‎选页面里展示的应用‎逻辑一致: - 查看对话:始终显示,点击后跳转到应用对话页面 - 查看作品:如果查看的应用有 deployKey,会展示查看作品按钮,点击后新页面打开部署地址。部署地址目前为 localhost/{deployKey}(注意,部署地址和生成网站的浏览地址不同,不要修改错了) 2)点进查看详情进入应用对话页面时‍,直接就自动发送消息了 由于现在并没有记录应用的生成状态,无法在对⁡话详情页判断应用是否生成过,可以在跳转时增加一个 url 查询参数‎(比如 ?view=1),如果有这个参数,则不会默认发送一条消息。‎ 并且还要补充权限校验,如果不是自己的作品,禁用编辑框,并且鼠标放到编辑框上时,给出提示:无法在别人的作品下对话哦~ ``` AI 生成的效果如图,符合需求: ![[image-26a7eab6.png]] ### 查看应用详情面板 其他大厂平台在对话页面的左上角有个应用信息的查看入口: ![[image-73360cb2.png]] 我们可以参考这种形式,在部署按钮左侧增加 `应用详情` 按钮,点击后展示悬浮窗,窗口内从上到下展示: - 应用基础信息: - 创建者:展示头像和昵称 - 创建时间 - 操作栏(仅本人或管理员可见): - 修改按钮 - 删除按钮 给 AI 发送提示词: ```yaml 在对话详情‍页的部署按钮左侧增⁡加【应用详情】按钮‎,点击后展示悬浮窗‎,窗口内从上到下展示: - 应用基础信息: - 创建者:展示头像和昵称 - 创建时间 - 操作栏(仅本人或管理员可见): - 修改按钮 - 删除按钮 ``` 生成效果如图: ![[image-6253e9a8.png]] ### 6、用户修改自己的应用信息 AI 已经顺利生成了单独的修改页面,如图: ![[image-1b1970b7.png]]![[image-be8dc2e3.png]] 效果还是不错的,但是要注意校验权限,只有管理员能修改优先级。 ### 7、用户删除自己的应用 AI 已经顺利生成,可以成功删除 ![[image-d552bd5a.png]] ### 8、部署应用 点击部署按钮,可以成功部署并访问: ![[image-76be87aa.png]] 而且部署应用后,在应用卡片上鼠标悬浮,就能看到 `查看作品` 按钮了。 后端支持多次部署,新的文件会覆盖旧的。 ### 9、管理员相关功能 最后再验收管理员相关功能,那就是洒洒水了~ 1)管理员删除任意应用 测试时可以让其他用户创建一个应用(改库),然后验证能否删除。 2)管理员更新任意应用信息,支持更新应用名称、应用封面、优先级。 3)管理员查看任意应用详情 因为目前没有限制只能查看自己的应用,所以这个功能和普通用户查看应用详情是一样的,只不过普通用户看不到非精选的应用列表。 4)管理员分页查询应用列表,支持根据除时间外的任何字段查询、且每页数量不限。 上述功能验证时基本没有什么问题,除了一个小问题: ### 常见问题 - 生成类型错误 AI 过于自由发挥了,下拉选择框内多了几个我们不支持的生成类型: ![[image-dd460a8f.png]] 让 AI 通过创建常量文件来管理生成类型,提示词如下: ```java 应用管理页‍面的生成类型错误,⁡需要单独创建常量文‎件来管理生成类型,‎类型参考后端枚举类: public enum CodeGenTypeEnum { HTML("原生 HTML 模式", "html"), MULTI_FILE("原生多文件模式", "multi_file"); ``` AI 创建的常量文件如下,这段代码也值得我们学习 ```javascript /** * 代码生成类型枚举 */ export enum CodeGenTypeEnum { HTML = 'html', MULTI_FILE = 'multi_file', } /** * 代码生成类型配置 */ export const CODE_GEN_TYPE_CONFIG = { [CodeGenTypeEnum.HTML]: { label: '原生 HTML 模式', value: CodeGenTypeEnum.HTML, }, [CodeGenTypeEnum.MULTI_FILE]: { label: '原生多文件模式', value: CodeGenTypeEnum.MULTI_FILE, }, } as const /** * 代码生成类型选项(用于下拉选择) */ export const CODE_GEN_TYPE_OPTIONS = Object.values(CODE_GEN_TYPE_CONFIG).map((config) => ({ label: config.label, value: config.value, })) ``` 修复完成,最终效果如图,成功支持应用筛选和列表展示: ![[image-655f03e0.png]] ### 前端优化 功能验收完成后,我们就可以根据自己的想法,对页面进行优化了。我这里从功能、样式、代码层面都进行了优化。 ### 1、优化 AI 输出效果 由于 AI 输出包含代码,可以采用 Markdown 格式展示 + 代码高亮。 给 AI 的提示词: ```text 优化应用对话详情‍页实时展示的 AI 回复的 Ma⁡rkdown 内容样式,采用主流‎的 Markdown 解析库来渲‎染,支持 HTML、CSS、JavaScript 的代码高亮 ``` AI 使用了 `markdown-it` 组件 + `Highlight.js` 实现代码高亮,效果很不错: ![[image-5ea7c962.png]] ### 2、优化 AI 头像展示 在 `assets` 目录下放一个头像文件 `aiAvatar.png`,体积小一点。 ![[image-6a941a6f.png]] 让 AI 帮忙修改: ```text 请将应用对话页中 AI 的头像改为 assets/aiAvatar.png ``` 效果如图: ![[image-e453c0f2.png]] ### 3、优化应用卡片的展示 参考大厂平台,可以给应用卡片补充用户头像,让页面内容更丰富: ![[image-ced5e961.png]] 让 AI 生成: ```yaml 优化应用卡片中信息的展示,左右结构: - 左侧:创建应用的用户头像 - 右侧:上下结构 - 上方:应用标题 - 下方:创建应用的用户昵称 ``` 效果如图: ![[image-b87af8da.png]] ### 4、优化配置 之前 AI 是把应用的部署域名和应用生成预览域名都写死在了代码中,不利于后续部署上线。 可以把这些提取为单独的环境变量配置,让 AI 帮忙修改: ```text 应用的部署域名和应用生成预览域名单独提取为环境变量配置 ``` AI 生成了环境变量配置文件 `env.ts`,并提供了获取完整 URL 的函数: ```javascript /** * 环境变量配置 */ // 应用部署域名 export const DEPLOY_DOMAIN = import.meta.env.VITE_DEPLOY_DOMAIN || '' // API 基础地址 export const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || '' // 静态资源地址 export const STATIC_BASE_URL = `${API_BASE_URL}/static` // 获取部署应用的完整URL export const getDeployUrl = (deployKey: string) => { return `${DEPLOY_DOMAIN}/${deployKey}` } // 获取静态资源预览URL export const getStaticPreviewUrl = (codeGenType: string, appId: string) => { return `${STATIC_BASE_URL}/${codeGenType}_${appId}/` } ``` 相关文件直接使用 `getDeployUrl` 和 `getStaticPreviewUrl` 获取地址即可,`request.ts` 也要改为使用 `API_BASE_URL` 作为请求域名前缀。 AI 还生成了环境变量配置说明: ```properties 在项目根目录创建 .env.local 或 .env.development 文件,并添加以下配置: # 应用部署域名 VITE_DEPLOY_DOMAIN=http://localhost # API 基础地址 VITE_API_BASE_URL=http://localhost:8123/api 生产环境可以创建 .env.production 文件: # 生产环境配置示例 VITE_DEPLOY_DOMAIN=https://your-domain.com VITE_API_BASE_URL=https://api.your-domain.com ``` 按照说明,我们在前端根目录下新建开发环境配置文件 `.env.development`: ```properties VITE_DEPLOY_DOMAIN=http://localhost VITE_API_BASE_URL=http://localhost:8123/api ``` 涉及的文件修改如图: ![[image-803a2a15.png]] ### 5、优化样式,并移除多余的功能 要优化的样式比较多,但每一点改动都不大,所以直接让 AI 一把梭: ```text 优化网站的样式,并移除多余的功能,包括: 1)主页增加充‍满科技感的渐变色背景,宽度⁡ 100%,主页其他元素样‎式配合修改 2)移除主页 ‎SLOGAN 位置的背景,SLOGAN 修改为: - 主标语:AI 应用生成平台 - 副标语:一句话轻松创建网站应用 3)优化主页输入框的展示效果,移除多余的‍边框和 “优化” 按钮,输入框的提示语改为 “帮我创建个人博客网站” 4)应用对⁡话页面移除布局中多余的间距,让页面看起来更精简,左侧对话区域和右侧网站展示区域的‎宽度比例为 2:3 5)应用对话页面的输入框提示语改为 “请描述你想生成的网站,‎越详细效果越好哦” 6)更换主页的 4 个快捷提示词示例,改为几个常见的网站生成需求,每个示例对应的提示词更具体一些,大概 100 字左右 ``` 💡 找渐变色可以试试 [这个网站](https://color.oulu.me/) ### 6、优化代码 对于有一定体量、功能较多的项目,建议在 AI 生成代码后,让它对代码质量进行检查,并且重复的代码可以抽象为组件,便于项目的维护。 直接让 AI 来分析: ```text 请检查整个‍前端项目中的代码,⁡将重复的内容、或者‎适合独立编写的内容‎抽象为组件以复用,比如: - 应用卡片 - 应用详情弹窗 - 其他需要抽象的组件。。。 并且移除没用到的多余代码 ``` AI 生成了任务列表,这也是现在的 Agent 基本都有的特性,可以通过这个任务列表来检查 AI 的意图是否符合预期。 ![[image-621196d5.png]] ## 七、成果展示 经过开发,用户可以通过简单的对话创建网站应用、实时查看生成效果、并一键部署,整个项目的核心流程至此就开发完成了。 主页: ![[image-91f478ab.png]] 查看精选应用: ![[image-5ccd38cb.png]] 对话页面,生成网站后可以实时查看效果: ![[image-47c86aea.png]] 部署应用: ![[image-29b84427.png]] 查看应用详情: ![[image-29b84427.png]] 编辑应用信息: ![[image-25620529.png]] 管理应用: ![[image-a16a595f.png]] ## 八、扩展思路 通过本节开发,我们建立了完整的应用管理体系,但还有很多可以优化和扩展的方向: ### 1、应用版本化管理 在实际使用中,用户往往需要对生成的应用进行多次迭代和修改。可以引入版本化管理机制: - 为每个应用增加版本号字段,每次 AI 生成新的代码时自动递增版本号 - 保存每个版本对应的代码文件,用户可以随时回退到历史版本 - 提供版本对比功能,让用户清楚地看到不同版本之间的差异(类似下图的效果) ![[image-49416383.png]] ### 2、AI 生成应用名称 当用户创建应用时,可以根据用户的初始描述自动生成合适的应用名称,提升体验。 ### 3、应用可见范围 为了更好地保护用户隐私: - 增加可见范围字段,支持公开、私有等多种权限设置 - 普通用户只能看到别人公开的应用,管理员可以查看所有应用 ### 4、应用分类和检索优化 1)标签系统:给应用增加标签功能,用户可以为自己的应用添加标签,其他用户可以通过标签筛选感兴趣的应用类型。 2)搜索优化:支持让用户根据应用名称、描述、标签等多维度搜索应用,甚至引入 ES 优化搜索效果,但是注意控制可见应用数量(防止爬虫)。 ### 5、用户体验优化 1)AI 生成控制:支持手动停止 AI 生成过程,避免用户在不满意当前生成内容时还需要等待完成。需要前后端配合,前端发送给后端一个事件,后端中断掉当前输出(可能涉及到深入 AI Flux 流去中断)。 2)部署控制:可以控制应用的部署开启和暂停,让用户能够灵活管理应用的在线状态,也就是取消部署(下线)。 3)应用生成状态:增加生成状态字段,记录应用是否生成完成、正在生成中等状态,让用户能够实时了解应用的生成进度。 ### 6、精选置顶 1)申请精选:提供用户申请精选功能,优质应用可以申请被平台推荐展示 2)应用置顶:增加应用置顶功能,可以通过设置优先级字段(如 Priority 为 999)来实现重要应用的优先展示。 ### 7、其他 1)数据清理:删除应用时,关联删除 AI 生成的文件和相关数据,避免垃圾数据堆积。 --- **项目分区导航**:⬅️ [[09-工程项目生成|09-工程项目生成]] | 10-应用模块 | ➡️ [[11-用户模块|11-用户模块]]