Explorar el Código

feat(server): 完善功能管理FTP列表功能,系统管理FTP列表功能

wxyshine hace 4 meses
padre
commit
9a709923cd
Se han modificado 22 ficheros con 5052 adiciones y 14 borrados
  1. 211 0
      modules/server/src/main/java/org/dromara/jpom/controller/ftp/FtpController.java
  2. 66 0
      modules/server/src/main/java/org/dromara/jpom/controller/ftp/FtpFileController.java
  3. 653 0
      modules/server/src/main/java/org/dromara/jpom/func/assets/controller/BaseFtpFileController.java
  4. 332 5
      modules/server/src/main/java/org/dromara/jpom/func/assets/controller/MachineFtpController.java
  5. 74 0
      modules/server/src/main/java/org/dromara/jpom/func/assets/controller/MachineFtpFileController.java
  6. 1 1
      modules/server/src/main/java/org/dromara/jpom/func/assets/controller/MachineSshController.java
  7. 2 2
      modules/server/src/main/java/org/dromara/jpom/func/assets/model/MachineFtpModel.java
  8. 5 1
      modules/server/src/main/java/org/dromara/jpom/func/assets/server/MachineFtpServer.java
  9. 125 0
      modules/server/src/main/java/org/dromara/jpom/model/data/FtpModel.java
  10. 18 4
      modules/server/src/main/java/org/dromara/jpom/permission/ClassFeature.java
  11. 239 0
      modules/server/src/main/java/org/dromara/jpom/service/node/ftp/FtpService.java
  12. 11 0
      modules/server/src/main/resources/menus/zh-CN/index.json
  13. 5 0
      modules/server/src/main/resources/menus/zh-CN/system.json
  14. 22 1
      modules/server/src/main/resources/sql-view/table.all.v1.2.csv
  15. 227 0
      web-vue/src/api/ftp-file.ts
  16. 68 0
      web-vue/src/api/ftp.ts
  17. 123 0
      web-vue/src/api/system/assets-ftp.ts
  18. 1281 0
      web-vue/src/pages/ftp/ftp-file.vue
  19. 604 0
      web-vue/src/pages/ftp/ftp.vue
  20. 973 0
      web-vue/src/pages/system/assets/ftp/ftp-list.vue
  21. 10 0
      web-vue/src/router/index.ts
  22. 2 0
      web-vue/src/router/route-menu.ts

+ 211 - 0
modules/server/src/main/java/org/dromara/jpom/controller/ftp/FtpController.java

@@ -0,0 +1,211 @@
+/*
+ * Copyright (c) 2019 Of Him Code Technology Studio
+ * Jpom is licensed under Mulan PSL v2.
+ * You can use this software according to the terms and conditions of the Mulan PSL v2.
+ * You may obtain a copy of Mulan PSL v2 at:
+ * 			http://license.coscl.org.cn/MulanPSL2
+ * THIS SOFTWARE IS PROVIDED ON AN "AS IS" BASIS, WITHOUT WARRANTIES OF ANY KIND, EITHER EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO NON-INFRINGEMENT, MERCHANTABILITY OR FIT FOR A PARTICULAR PURPOSE.
+ * See the Mulan PSL v2 for more details.
+ */
+
+package org.dromara.jpom.controller.ftp;
+
+import cn.hutool.core.collection.CollUtil;
+import cn.hutool.core.lang.tree.Tree;
+import cn.hutool.core.lang.tree.TreeNode;
+import cn.hutool.core.lang.tree.TreeUtil;
+import cn.hutool.core.util.StrUtil;
+import cn.hutool.crypto.SecureUtil;
+import cn.keepbx.jpom.IJsonMessage;
+import cn.keepbx.jpom.model.JsonMessage;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.stream.Collectors;
+import javax.servlet.http.HttpServletRequest;
+import lombok.extern.slf4j.Slf4j;
+import org.dromara.jpom.common.BaseServerController;
+import org.dromara.jpom.common.i18n.I18nMessageUtil;
+import org.dromara.jpom.common.validator.ValidatorItem;
+import org.dromara.jpom.common.validator.ValidatorRule;
+import org.dromara.jpom.func.assets.server.MachineFtpServer;
+import org.dromara.jpom.model.PageResultDto;
+import org.dromara.jpom.model.data.FtpModel;
+import org.dromara.jpom.model.data.NodeModel;
+import org.dromara.jpom.model.enums.BuildReleaseMethod;
+import org.dromara.jpom.permission.ClassFeature;
+import org.dromara.jpom.permission.Feature;
+import org.dromara.jpom.permission.MethodFeature;
+import org.dromara.jpom.permission.SystemPermission;
+import org.dromara.jpom.service.dblog.BuildInfoService;
+import org.dromara.jpom.service.node.ftp.FtpService;
+import org.springframework.http.MediaType;
+import org.springframework.util.Assert;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+/**
+ * @author wxy
+ * @since 2025/05/29
+ */
+@RestController
+@RequestMapping(value = "node/ftp")
+@Feature(cls = ClassFeature.FTP)
+@Slf4j
+public class FtpController extends BaseServerController {
+
+    private final FtpService ftpService;
+    private final MachineFtpServer machineFtpServer;
+
+
+    private final BuildInfoService buildInfoService;
+
+    public FtpController(FtpService ftpService, MachineFtpServer machineFtpServer,
+                         BuildInfoService buildInfoService) {
+        this.ftpService = ftpService;
+        this.machineFtpServer = machineFtpServer;
+        this.buildInfoService = buildInfoService;
+    }
+
+
+    @PostMapping(value = "list_data.json", produces = MediaType.APPLICATION_JSON_VALUE)
+    @Feature(method = MethodFeature.LIST)
+    public IJsonMessage<PageResultDto<FtpModel>> listData(HttpServletRequest request) {
+        PageResultDto<FtpModel> pageResultDto = ftpService.listPage(request);
+        pageResultDto.each(ftpModel -> {
+            ftpModel.setMachineFtp(machineFtpServer.getByKey(ftpModel.getMachineFtpId()));
+           /* List<NodeModel> nodeBySshId = nodeService.getNodeBySshId(ftpModel.getId());
+            ftpModel.setLinkNode(CollUtil.getFirst(nodeBySshId));*/
+        });
+        return new JsonMessage<>(200, "", pageResultDto);
+    }
+
+    @GetMapping(value = "list_data_all.json", produces = MediaType.APPLICATION_JSON_VALUE)
+    @Feature(method = MethodFeature.LIST)
+    public IJsonMessage<List<FtpModel>> listDataAll(HttpServletRequest request) {
+        List<FtpModel> list = ftpService.listByWorkspace(request);
+        return new JsonMessage<>(200, "", list);
+    }
+
+    @GetMapping(value = "list-tree", produces = MediaType.APPLICATION_JSON_VALUE)
+    @Feature(method = MethodFeature.LIST)
+    public IJsonMessage<Tree<String>> listTree(HttpServletRequest request) {
+        List<FtpModel> list = ftpService.listByWorkspace(request);
+        Map<String, TreeNode<String>> groupNode = new HashMap<>(4);
+        List<TreeNode<String>> treeNodes = list.stream()
+            .map(ftpModel -> {
+                String group = ftpModel.getGroup();
+                String groupId = SecureUtil.sha1(StrUtil.emptyToDefault(group, StrUtil.EMPTY));
+                String groupId2 = StrUtil.format("g_{}", groupId);
+                groupNode.computeIfAbsent(groupId, s -> new TreeNode<>(groupId2, StrUtil.SLASH, group, ftpModel.getName()));
+                //
+                TreeNode<String> treeNode = new TreeNode<>(ftpModel.getId(), groupId2, ftpModel.getName(), ftpModel.getName());
+                Map<String, Object> extra = new HashMap<>();
+                extra.put("fileDirs", ftpModel.getFileDirs());
+                extra.put("isLeaf", true);
+                treeNode.setExtra(extra);
+                return treeNode;
+            })
+            .collect(Collectors.toList());
+        //
+        treeNodes.addAll(groupNode.values());
+        Tree<String> tree = TreeUtil.buildSingle(treeNodes, StrUtil.SLASH);
+        return new JsonMessage<>(200, "", tree);
+    }
+
+    @GetMapping(value = "get-item.json", produces = MediaType.APPLICATION_JSON_VALUE)
+    @Feature(method = MethodFeature.LIST)
+    public IJsonMessage<FtpModel> getItem(@ValidatorItem String id, HttpServletRequest request) {
+        FtpModel byKey = ftpService.getByKey(id, request);
+        Assert.notNull(byKey, I18nMessageUtil.get("i18n.ssh_does_not_exist_with_message.de6c"));
+        return new JsonMessage<>(200, "", byKey);
+    }
+
+    /**
+     * 查询所有的分组
+     *
+     * @return list
+     */
+    @GetMapping(value = "list-group-all", produces = MediaType.APPLICATION_JSON_VALUE)
+    @Feature(method = MethodFeature.LIST)
+    public IJsonMessage<List<String>> listGroupAll(HttpServletRequest request) {
+        List<String> listGroup = ftpService.listGroup(request);
+        return JsonMessage.success("", listGroup);
+    }
+
+    /**
+     * 编辑
+     *
+     * @param name    名称
+     * @param group   分组名
+     * @param request 请求对象
+     * @param id      ID
+     * @return json
+     */
+    @PostMapping(value = "save.json", produces = MediaType.APPLICATION_JSON_VALUE)
+    @Feature(method = MethodFeature.EDIT)
+    public IJsonMessage<String> save(@ValidatorItem(value = ValidatorRule.NOT_BLANK, msg = "i18n.parameter_error_ssh_name_cannot_be_empty.ff4f") String name,
+                                     String id,
+                                     String group,
+                                     HttpServletRequest request) {
+        FtpModel ftpModel = new FtpModel();
+        ftpModel.setName(name);
+        ftpModel.setGroup(group);
+        ftpModel.setId(id);
+        ftpService.updateById(ftpModel, request);
+        return JsonMessage.success(I18nMessageUtil.get("i18n.operation_succeeded.3313"));
+    }
+
+
+    @PostMapping(value = "del.json", produces = MediaType.APPLICATION_JSON_VALUE)
+    @Feature(method = MethodFeature.DEL)
+    public IJsonMessage<Object> del(@ValidatorItem(value = ValidatorRule.NOT_BLANK) String id, HttpServletRequest request) {
+        boolean checkSsh = buildInfoService.checkReleaseMethodByLike(id, request, BuildReleaseMethod.Ssh);
+        Assert.state(!checkSsh, I18nMessageUtil.get("i18n.ssh_with_build_items_message.0f6d"));
+        // 判断是否绑定节点
+        List<NodeModel> nodeBySshId = nodeService.getNodeBySshId(id);
+        Assert.state(CollUtil.isEmpty(nodeBySshId), I18nMessageUtil.get("i18n.ssh_bound_to_node_message.7b64"));
+
+        ftpService.delByKey(id, request);
+        //
+        return JsonMessage.success(I18nMessageUtil.get("i18n.operation_succeeded.3313"));
+    }
+
+    @PostMapping(value = "del-fore", produces = MediaType.APPLICATION_JSON_VALUE)
+    @Feature(method = MethodFeature.DEL)
+    @SystemPermission
+    public IJsonMessage<Object> delFore(@ValidatorItem(value = ValidatorRule.NOT_BLANK) String id) {
+        boolean checkSsh = buildInfoService.checkReleaseMethodByLike(id, BuildReleaseMethod.Ssh);
+        Assert.state(!checkSsh, I18nMessageUtil.get("i18n.ssh_with_build_items_message.0f6d"));
+        // 判断是否绑定节点
+        List<NodeModel> nodeBySshId = nodeService.getNodeBySshId(id);
+        Assert.state(CollUtil.isEmpty(nodeBySshId), I18nMessageUtil.get("i18n.ssh_bound_to_node_message.7b64"));
+
+        ftpService.delByKey(id);
+        //
+        return JsonMessage.success(I18nMessageUtil.get("i18n.operation_succeeded.3313"));
+    }
+
+
+    /**
+     * 同步到指定工作空间
+     *
+     * @param ids           节点ID
+     * @param toWorkspaceId 分配到到工作空间ID
+     * @return msg
+     */
+    @GetMapping(value = "sync-to-workspace", produces = MediaType.APPLICATION_JSON_VALUE)
+    @Feature(method = MethodFeature.EDIT)
+    @SystemPermission()
+    public IJsonMessage<Object> syncToWorkspace(@ValidatorItem String ids,
+                                                @ValidatorItem String toWorkspaceId,
+                                                HttpServletRequest request) {
+        String nowWorkspaceId = nodeService.getCheckUserWorkspace(request);
+        //
+        ftpService.checkUserWorkspace(toWorkspaceId);
+        ftpService.syncToWorkspace(ids, nowWorkspaceId, toWorkspaceId);
+        return JsonMessage.success(I18nMessageUtil.get("i18n.operation_succeeded.3313"));
+    }
+}

+ 66 - 0
modules/server/src/main/java/org/dromara/jpom/controller/ftp/FtpFileController.java

@@ -0,0 +1,66 @@
+/*
+ * Copyright (c) 2019 Of Him Code Technology Studio
+ * Jpom is licensed under Mulan PSL v2.
+ * You can use this software according to the terms and conditions of the Mulan PSL v2.
+ * You may obtain a copy of Mulan PSL v2 at:
+ * 			http://license.coscl.org.cn/MulanPSL2
+ * THIS SOFTWARE IS PROVIDED ON AN "AS IS" BASIS, WITHOUT WARRANTIES OF ANY KIND, EITHER EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO NON-INFRINGEMENT, MERCHANTABILITY OR FIT FOR A PARTICULAR PURPOSE.
+ * See the Mulan PSL v2 for more details.
+ */
+
+package org.dromara.jpom.controller.ftp;
+
+import cn.hutool.core.collection.CollUtil;
+import cn.hutool.core.io.FileUtil;
+import cn.hutool.core.lang.Opt;
+import cn.hutool.core.util.StrUtil;
+import java.util.List;
+import java.util.function.BiFunction;
+import lombok.extern.slf4j.Slf4j;
+import org.dromara.jpom.common.i18n.I18nMessageUtil;
+import org.dromara.jpom.func.assets.controller.BaseFtpFileController;
+import org.dromara.jpom.func.assets.model.MachineFtpModel;
+import org.dromara.jpom.model.data.FtpModel;
+import org.dromara.jpom.permission.ClassFeature;
+import org.dromara.jpom.permission.Feature;
+import org.dromara.jpom.util.FileUtils;
+import org.springframework.util.Assert;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+/**
+ * ftp 文件管理
+ *
+ * @author wxy
+ * @since 2025/06/04
+ */
+@RestController
+@RequestMapping("node/ftp")
+@Feature(cls = ClassFeature.FTP_FILE)
+@Slf4j
+public class FtpFileController extends BaseFtpFileController {
+
+
+    @Override
+    protected <T> T checkConfigPath(String id, BiFunction<MachineFtpModel, ItemConfig, T> function) {
+        FtpModel ftpModel = ftpService.getByKey(id);
+        Assert.notNull(ftpModel, I18nMessageUtil.get("i18n.no_corresponding_ssh.aa68"));
+        MachineFtpModel machineFtpModel = machineFtpServer.getByKey(ftpModel.getMachineFtpId(), false);
+        return function.apply(machineFtpModel, ftpModel);
+    }
+
+    @Override
+    protected <T> T checkConfigPathChildren(String id, String path, String children, BiFunction<MachineFtpModel, ItemConfig, T> function) {
+        FileUtils.checkSlip(path);
+        Opt.ofBlankAble(children).ifPresent(FileUtils::checkSlip);
+
+        FtpModel ftpModel = ftpService.getByKey(id);
+        Assert.notNull(ftpModel, I18nMessageUtil.get("i18n.no_corresponding_ssh.aa68"));
+        List<String> fileDirs = ftpModel.fileDirs();
+        String normalize = FileUtil.normalize(StrUtil.SLASH + path + StrUtil.SLASH);
+        //
+        Assert.state(CollUtil.contains(fileDirs, normalize), I18nMessageUtil.get("i18n.cannot_operate_current_directory.aa3d"));
+        MachineFtpModel machineFtpModel = machineFtpServer.getByKey(ftpModel.getMachineFtpId(), false);
+        return function.apply(machineFtpModel, ftpModel);
+    }
+}

+ 653 - 0
modules/server/src/main/java/org/dromara/jpom/func/assets/controller/BaseFtpFileController.java

@@ -0,0 +1,653 @@
+/*
+ * Copyright (c) 2019 Of Him Code Technology Studio
+ * Jpom is licensed under Mulan PSL v2.
+ * You can use this software according to the terms and conditions of the Mulan PSL v2.
+ * You may obtain a copy of Mulan PSL v2 at:
+ * 			http://license.coscl.org.cn/MulanPSL2
+ * THIS SOFTWARE IS PROVIDED ON AN "AS IS" BASIS, WITHOUT WARRANTIES OF ANY KIND, EITHER EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO NON-INFRINGEMENT, MERCHANTABILITY OR FIT FOR A PARTICULAR PURPOSE.
+ * See the Mulan PSL v2 for more details.
+ */
+
+package org.dromara.jpom.func.assets.controller;
+
+import cn.hutool.core.collection.CollUtil;
+import cn.hutool.core.convert.Convert;
+import cn.hutool.core.exceptions.ExceptionUtil;
+import cn.hutool.core.io.FileUtil;
+import cn.hutool.core.io.IoUtil;
+import cn.hutool.core.lang.Assert;
+import cn.hutool.core.util.CharsetUtil;
+import cn.hutool.core.util.EnumUtil;
+import cn.hutool.core.util.IdUtil;
+import cn.hutool.core.util.ObjectUtil;
+import cn.hutool.core.util.StrUtil;
+import cn.hutool.core.util.URLUtil;
+import cn.hutool.crypto.SecureUtil;
+import cn.hutool.extra.ftp.Ftp;
+import cn.hutool.extra.ftp.FtpMode;
+import cn.hutool.extra.servlet.ServletUtil;
+import cn.keepbx.jpom.IJsonMessage;
+import cn.keepbx.jpom.model.JsonMessage;
+import com.alibaba.fastjson2.JSONArray;
+import com.alibaba.fastjson2.JSONObject;
+import com.jcraft.jsch.SftpException;
+import java.io.ByteArrayOutputStream;
+import java.io.File;
+import java.io.IOException;
+import java.io.InputStream;
+import java.nio.charset.Charset;
+import java.util.ArrayList;
+import java.util.Calendar;
+import java.util.List;
+import java.util.TimeZone;
+import java.util.function.BiFunction;
+import javax.annotation.Resource;
+import javax.servlet.http.HttpServletResponse;
+import lombok.Lombok;
+import lombok.extern.slf4j.Slf4j;
+import org.apache.commons.net.ftp.FTPFile;
+import org.dromara.jpom.common.BaseServerController;
+import org.dromara.jpom.common.i18n.I18nMessageUtil;
+import org.dromara.jpom.common.validator.ValidatorItem;
+import org.dromara.jpom.func.assets.model.MachineFtpModel;
+import org.dromara.jpom.func.assets.server.MachineFtpServer;
+import org.dromara.jpom.model.data.AgentWhitelist;
+import org.dromara.jpom.permission.Feature;
+import org.dromara.jpom.permission.MethodFeature;
+import org.dromara.jpom.service.node.ftp.FtpService;
+import org.dromara.jpom.system.ServerConfig;
+import org.dromara.jpom.util.CommandUtil;
+import org.dromara.jpom.util.CompressionFileUtil;
+import org.dromara.jpom.util.StringUtil;
+import org.springframework.http.MediaType;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RequestMethod;
+import org.springframework.web.multipart.MultipartFile;
+
+/**
+ * @author wxyShine
+ * @since 2025/05/28
+ * 临时测试ftp服务器 https://sftpcloud.io/tools/free-ftp-server
+ */
+@Slf4j
+public abstract class BaseFtpFileController extends BaseServerController {
+
+    @Resource
+    protected FtpService ftpService;
+    @Resource
+    protected MachineFtpServer machineFtpServer;
+    @Resource
+    private ServerConfig serverConfig;
+
+    public interface ItemConfig {
+        /**
+         * 允许编辑的文件后缀
+         *
+         * @return 文件后缀
+         */
+        List<String> allowEditSuffix();
+
+        /**
+         * 允许管理的文件目录
+         *
+         * @return 文件目录
+         */
+        List<String> fileDirs();
+    }
+
+    /**
+     * 验证数据id 和目录合法性
+     *
+     * @param id       数据id
+     * @param function 回调
+     * @param <T>      泛型
+     * @return 处理后的数据
+     */
+    protected abstract <T> T checkConfigPath(String id, BiFunction<MachineFtpModel, ItemConfig, T> function);
+
+    /**
+     * 验证数据id 和目录合法性
+     *
+     * @param id              数据id
+     * @param allowPathParent 想要验证的目录 (授权)
+     * @param nextPath        授权后的二级路径
+     * @param function        回调
+     * @param <T>             泛型
+     * @return 处理后的数据
+     */
+    protected abstract <T> T checkConfigPathChildren(String id, String allowPathParent, String nextPath, BiFunction<MachineFtpModel, ItemConfig, T> function);
+
+    @RequestMapping(value = "download", method = RequestMethod.GET)
+    @Feature(method = MethodFeature.DOWNLOAD)
+    public void download(@ValidatorItem String id,
+                         @ValidatorItem String allowPathParent,
+                         @ValidatorItem String nextPath,
+                         @ValidatorItem String name,
+                         HttpServletResponse response) throws IOException {
+        MachineFtpModel machineFtpModel = this.checkConfigPathChildren(id, allowPathParent, nextPath, (machineFtpModel1, itemConfig) -> machineFtpModel1);
+        if (machineFtpModel == null) {
+            ServletUtil.write(response, I18nMessageUtil.get("i18n.ssh_error_or_folder_not_configured.c087"), MediaType.TEXT_HTML_VALUE);
+            return;
+        }
+        this.downloadFile(machineFtpModel, allowPathParent, nextPath, name, response);
+    }
+
+    /**
+     * 根据 id 获取 fileDirs 目录集合
+     *
+     * @param id ftp id
+     * @return json
+     * @author Hotstrip
+     * @since for dev 3.x
+     */
+    @RequestMapping(value = "root_file_data.json", method = RequestMethod.POST, produces = MediaType.APPLICATION_JSON_VALUE)
+    @Feature(method = MethodFeature.LIST)
+    public IJsonMessage<JSONArray> rootFileList(@ValidatorItem String id) {
+        //
+        return this.checkConfigPath(id, (machineFtpModel, itemConfig) -> {
+            JSONArray listDir = listRootDir(machineFtpModel, itemConfig.fileDirs());
+            return JsonMessage.success("", listDir);
+        });
+    }
+
+
+    @RequestMapping(value = "list_file_data.json", method = RequestMethod.POST, produces = MediaType.APPLICATION_JSON_VALUE)
+    @Feature(method = MethodFeature.LIST)
+    public IJsonMessage<JSONArray> listData(@ValidatorItem String id,
+                                            @ValidatorItem String allowPathParent,
+                                            @ValidatorItem String nextPath) {
+        return this.checkConfigPathChildren(id, allowPathParent, nextPath, (machineFtpModel, itemConfig) -> {
+            try {
+                JSONArray listDir = listDir(machineFtpModel, allowPathParent, nextPath, itemConfig);
+                return JsonMessage.success("", listDir);
+            } catch (SftpException e) {
+                throw Lombok.sneakyThrow(e);
+            }
+        });
+    }
+
+    @RequestMapping(value = "read_file_data.json", method = RequestMethod.POST, produces = MediaType.APPLICATION_JSON_VALUE)
+    @Feature(method = MethodFeature.LIST)
+    public IJsonMessage<String> readFileData(@ValidatorItem String id,
+                                             @ValidatorItem String allowPathParent,
+                                             @ValidatorItem String nextPath,
+                                             @ValidatorItem String name) {
+        return this.checkConfigPathChildren(id, allowPathParent, nextPath, (machineFtpModel, itemConfig) -> {
+
+            List<String> allowEditSuffix = itemConfig.allowEditSuffix();
+            Charset charset = AgentWhitelist.checkFileSuffix(allowEditSuffix, name);
+            //
+            String content = this.readFile(machineFtpModel, allowPathParent, nextPath, name, charset);
+            return JsonMessage.success("", content);
+        });
+    }
+
+    @RequestMapping(value = "update_file_data.json", method = RequestMethod.POST, produces = MediaType.APPLICATION_JSON_VALUE)
+    @Feature(method = MethodFeature.EDIT)
+    public IJsonMessage<String> updateFileData(@ValidatorItem String id,
+                                               @ValidatorItem String allowPathParent,
+                                               @ValidatorItem String nextPath,
+                                               @ValidatorItem String name,
+                                               @ValidatorItem String content) {
+        return this.checkConfigPathChildren(id, allowPathParent, nextPath, (machineFtpModel, itemConfig) -> {
+            //
+            List<String> allowEditSuffix = itemConfig.allowEditSuffix();
+            Charset charset = AgentWhitelist.checkFileSuffix(allowEditSuffix, name);
+            // 缓存到本地
+            File file = FileUtil.file(serverConfig.getUserTempPath(), machineFtpModel.getId(), allowPathParent, nextPath, name);
+            try {
+                FileUtil.writeString(content, file, charset);
+                // 上传
+                this.syncFile(machineFtpModel, allowPathParent, nextPath, name, file);
+            } finally {
+                //
+                FileUtil.del(file);
+            }
+            //
+            return JsonMessage.success(I18nMessageUtil.get("i18n.modify_success.69be"));
+        });
+    }
+
+    /**
+     * 读取文件
+     *
+     * @param machineFtpModel ftp
+     * @param allowPathParent 路径
+     * @param nextPath        二级路径
+     * @param name            文件
+     * @param charset         编码格式
+     */
+    private String readFile(MachineFtpModel machineFtpModel, String allowPathParent, String nextPath, String name, Charset charset) {
+        String normalize = FileUtil.normalize(allowPathParent + StrUtil.SLASH + nextPath + StrUtil.SLASH + name);
+
+        try (Ftp ftp = new Ftp(machineFtpServer.toFtpConfig(machineFtpModel),
+            EnumUtil.fromString(FtpMode.class, machineFtpModel.getMode(), FtpMode.Active));
+             InputStream inputStream = ftp.getClient().retrieveFileStream(normalize);
+             ByteArrayOutputStream outputStream = new ByteArrayOutputStream()) {
+
+            if (inputStream == null) {
+                throw new RuntimeException("文件不存在或无法打开: " + normalize);
+            }
+
+            IoUtil.copy(inputStream, outputStream);
+            ftp.getClient().completePendingCommand();
+
+            return outputStream.toString(charset.name());
+
+        } catch (IOException e) {
+            throw new RuntimeException("FTP 读取文件失败", e);
+        }
+    }
+
+    /**
+     * 上传文件
+     *
+     * @param machineFtpModel ftp
+     * @param allowPathParent 路径
+     * @param nextPath        二级路径
+     * @param name            文件
+     * @param file            同步上传文件
+     */
+    private void syncFile(MachineFtpModel machineFtpModel,
+                          String allowPathParent,
+                          String nextPath,
+                          String name,
+                          File file) {
+        String normalizeDir = FileUtil.normalize(allowPathParent + StrUtil.SLASH + nextPath);
+
+        // 通过 EnumUtil.fromString 获取枚举,默认 FtpMode.Active
+        FtpMode ftpMode = EnumUtil.fromString(FtpMode.class, machineFtpModel.getMode(), FtpMode.Active);
+
+        try (Ftp ftp = new Ftp(machineFtpServer.toFtpConfig(machineFtpModel), ftpMode)) {
+            // 直接upload到原来目录 会覆盖更新文件
+            boolean success = ftp.upload(normalizeDir, file);
+            if (!success) {
+                throw new RuntimeException("FTP 上传失败");
+            }
+        } catch (IOException e) {
+            throw new RuntimeException("FTP 连接或操作异常", e);
+        }
+    }
+
+    /**
+     * 下载文件
+     *
+     * @param machineFtpModel ftp
+     * @param allowPathParent 路径
+     * @param name            文件
+     * @param response        响应
+     * @throws IOException   io
+     * @throws SftpException sftp
+     */
+    private void downloadFile(MachineFtpModel machineFtpModel, String allowPathParent, String nextPath, String name, HttpServletResponse response) throws IOException {
+        final String charset = ObjectUtil.defaultIfNull(response.getCharacterEncoding(), CharsetUtil.UTF_8);
+        String fileName = FileUtil.getName(name);
+
+        response.setHeader("Content-Disposition", StrUtil.format("attachment;filename={}", URLUtil.encode(fileName, Charset.forName(charset))));
+        response.setContentType(MediaType.APPLICATION_OCTET_STREAM_VALUE);
+
+        String normalize = FileUtil.normalize(allowPathParent + StrUtil.SLASH + nextPath + StrUtil.SLASH + name);
+
+        try (Ftp ftp = new Ftp(machineFtpServer.toFtpConfig(machineFtpModel),
+            EnumUtil.fromString(FtpMode.class, machineFtpModel.getMode(), FtpMode.Active));
+             InputStream inputStream = ftp.getClient().retrieveFileStream(normalize)) {
+
+            if (inputStream == null) {
+                throw new RuntimeException("文件不存在或无法下载: " + normalize);
+            }
+
+            IoUtil.copy(inputStream, response.getOutputStream());
+            ftp.getClient().completePendingCommand(); // 关键!
+
+        } catch (IOException e) {
+            throw new RuntimeException("FTP 下载文件失败", e);
+        }
+    }
+
+    /**
+     * 查询文件夹下所有文件
+     *
+     * @param ftpModel        ssh
+     * @param allowPathParent 允许的路径
+     * @param nextPath        下 N 级的文件夹
+     * @return array
+     * @throws SftpException sftp
+     */
+    @SuppressWarnings("unchecked")
+    private JSONArray listDir(MachineFtpModel ftpModel, String allowPathParent, String nextPath, ItemConfig itemConfig) throws SftpException {
+
+        List<String> allowEditSuffix = itemConfig.allowEditSuffix();
+        try (Ftp ftp = new Ftp(machineFtpServer.toFtpConfig(ftpModel),
+            EnumUtil.fromString(FtpMode.class, ftpModel.getMode(), FtpMode.Active))) {
+
+            String children2 = StrUtil.emptyToDefault(nextPath, StrUtil.SLASH);
+            String allPath = StrUtil.format("{}/{}", allowPathParent, children2);
+            allPath = FileUtil.normalize(allPath);
+            JSONArray jsonArray = new JSONArray();
+            FTPFile[] ftpFiles;
+            try {
+                ftpFiles = ftp.lsFiles(allPath);
+            } catch (Exception e) {
+                log.warn(I18nMessageUtil.get("i18n.get_folder_failure.0fda"), e);
+                Throwable causedBy = ExceptionUtil.getCausedBy(e, SftpException.class);
+                if (causedBy != null) {
+                    throw new IllegalStateException("无法查询文件夹FTP," + causedBy.getMessage());
+                }
+                throw new IllegalStateException(I18nMessageUtil.get("i18n.query_folder_failed.3f0e") + e.getMessage());
+            }
+            for (FTPFile file : ftpFiles) {
+                String filename = file.getName();
+                if (StrUtil.DOT.equals(filename) || StrUtil.DOUBLE_DOT.equals(filename)) {
+                    continue;
+                }
+                JSONObject jsonObject = new JSONObject();
+                jsonObject.put("name", filename);
+                jsonObject.put("id", SecureUtil.sha1(allPath + StrUtil.SLASH + filename));
+
+                Calendar timestamp = file.getTimestamp();
+                timestamp.setTimeZone(TimeZone.getTimeZone("UTC"));
+                jsonObject.put("modifyTime", timestamp);
+
+                jsonObject.put("dir", file.isDirectory());
+                jsonObject.put("size", file.getSize());
+                jsonObject.put("textFileEdit", AgentWhitelist.checkSilentFileSuffix(allowEditSuffix, filename));
+                jsonObject.put("longname", file.toFormattedString(TimeZone.getDefault().toString()));
+                jsonObject.put("link", file.getLink());
+                jsonObject.put("permissions", getPermissionString(file));
+                jsonObject.put("allowPathParent", allowPathParent);
+                jsonObject.put("nextPath", FileUtil.normalize(children2));
+                jsonArray.add(jsonObject);
+            }
+            return jsonArray;
+        } catch (IOException e) {
+            throw new RuntimeException(e);
+        }
+    }
+
+    /**
+     * 列出目前,判断是否存在
+     *
+     * @param ftpModel 数据信息
+     * @param list     目录
+     * @return Array
+     */
+    private JSONArray listRootDir(MachineFtpModel ftpModel, List<String> list) {
+        JSONArray jsonArray = new JSONArray();
+        if (CollUtil.isEmpty(list)) {
+            return jsonArray;
+        }
+
+        try (Ftp ftp = new Ftp(machineFtpServer.toFtpConfig(ftpModel),
+            EnumUtil.fromString(FtpMode.class, ftpModel.getMode(), FtpMode.Active))) {
+            for (String allowPathParent : list) {
+                JSONObject jsonObject = new JSONObject();
+                jsonObject.put("id", SecureUtil.sha1(allowPathParent));
+                jsonObject.put("allowPathParent", allowPathParent);
+                ftp.ls(allowPathParent);
+                jsonArray.add(jsonObject);
+            }
+        } catch (Exception e) {
+            log.error("连接FTP失败", e);
+        }
+        return jsonArray;
+    }
+
+
+    @RequestMapping(value = "delete.json", method = RequestMethod.POST, produces = MediaType.APPLICATION_JSON_VALUE)
+    @Feature(method = MethodFeature.DEL)
+    public IJsonMessage<String> delete(@ValidatorItem String id,
+                                       @ValidatorItem String allowPathParent,
+                                       @ValidatorItem String nextPath,
+                                       String name) {
+        // name 可能为空,为空情况是删除目录
+        String name2 = StrUtil.emptyToDefault(name, StrUtil.EMPTY);
+        Assert.state(!StrUtil.equals(name2, StrUtil.SLASH), I18nMessageUtil.get("i18n.cannot_delete_root_dir.fcdc"));
+        return this.checkConfigPathChildren(id, allowPathParent, nextPath, (machineFtpModel, itemConfig) -> {
+
+            try (Ftp ftp = new Ftp(machineFtpServer.toFtpConfig(machineFtpModel),
+                EnumUtil.fromString(FtpMode.class, machineFtpModel.getMode(), FtpMode.Active))) {
+                String normalize = FileUtil.normalize(allowPathParent + StrUtil.SLASH + nextPath + StrUtil.SLASH + name2);
+                Assert.state(!StrUtil.equals(normalize, StrUtil.SLASH), I18nMessageUtil.get("i18n.cannot_delete_root_dir.fcdc"));
+                // 尝试删除
+                boolean dirOrFile = this.tryDelDirOrFile(ftp, normalize);
+                if (dirOrFile) {
+                    String parent = FileUtil.getParent(normalize, 1);
+                    return JsonMessage.success(I18nMessageUtil.get("i18n.delete_success.0007"), parent);
+                }
+                return JsonMessage.success(I18nMessageUtil.get("i18n.delete_success.0007"));
+            } catch (Exception e) {
+                log.error(I18nMessageUtil.get("i18n.ssh_file_deletion_exception.5ba5"), e);
+                return new JsonMessage<>(400, I18nMessageUtil.get("i18n.delete_failure_with_colon.b429") + e.getMessage());
+            }
+        });
+    }
+
+    @RequestMapping(value = "rename.json", method = RequestMethod.POST, produces = MediaType.APPLICATION_JSON_VALUE)
+    @Feature(method = MethodFeature.EDIT)
+    public IJsonMessage<String> rename(@ValidatorItem String id,
+                                       @ValidatorItem String allowPathParent,
+                                       @ValidatorItem String nextPath,
+                                       @ValidatorItem String name,
+                                       @ValidatorItem String newname) {
+
+        return this.checkConfigPathChildren(id, allowPathParent, nextPath, (machineFtpModel, itemConfig) -> {
+
+            try (Ftp ftp = new Ftp(machineFtpServer.toFtpConfig(machineFtpModel),
+                EnumUtil.fromString(FtpMode.class, machineFtpModel.getMode(), FtpMode.Active))) {
+                String oldPath = FileUtil.normalize(allowPathParent + StrUtil.SLASH + nextPath + StrUtil.SLASH + name);
+                String newPath = FileUtil.normalize(allowPathParent + StrUtil.SLASH + nextPath + StrUtil.SLASH + newname);
+                ftp.getClient().rename(oldPath, newPath);
+            } catch (Exception e) {
+                log.error("FTP重命名失败异常", e);
+                return new JsonMessage<>(400, "FTP重命名失败异常" + e.getMessage());
+            }
+            return JsonMessage.success(I18nMessageUtil.get("i18n.operation_succeeded.3313"));
+        });
+    }
+
+    /**
+     * 删除文件 或者 文件夹
+     *
+     * @param ftp  ftp
+     * @param path 路径
+     * @return true 删除的是 文件夹
+     */
+    private boolean tryDelDirOrFile(Ftp ftp, String path) {
+        try {
+            // 先尝试删除文件夹
+            ftp.delDir(path);
+            return true;
+        } catch (Exception e) {
+            // 删除文件
+            ftp.delFile(path);
+        }
+        return false;
+    }
+
+    /**
+     * 上传分片
+     *
+     * @param file       文件对象
+     * @param sliceId    分片id
+     * @param totalSlice 总分片
+     * @param nowSlice   当前分片
+     * @param fileSumMd5 文件 md5
+     * @return json
+     */
+   /* @PostMapping(value = "upload-sharding", produces = MediaType.APPLICATION_JSON_VALUE)
+    @Feature(method = MethodFeature.UPLOAD, log = false)
+    public IJsonMessage<String> uploadSharding(MultipartFile file,
+                                               String sliceId,
+                                               Integer totalSlice,
+                                               Integer nowSlice,
+                                               String fileSumMd5,
+                                               @ValidatorItem String id,
+                                               @ValidatorItem String allowPathParent,
+                                               @ValidatorItem String nextPath) {
+        return this.checkConfigPathChildren(id, allowPathParent, nextPath, (machineSshModel, itemConfig) -> {
+            String remotePath = FileUtil.normalize(allowPathParent + StrUtil.SLASH + nextPath);
+            Session session = null;
+            ChannelSftp channel = null;
+            try {
+                session = sshService.getSessionByModel(machineSshModel);
+                channel = (ChannelSftp) JschUtil.openChannel(session, ChannelType.SFTP);
+                channel.cd(remotePath);
+                String originalFilename = file.getOriginalFilename();
+                // xxxx.txt.1
+                originalFilename = StrUtil.subBefore(originalFilename, ".", true);
+                if (nowSlice == 0) {
+                    channel.put(file.getInputStream(), originalFilename, ChannelSftp.OVERWRITE);
+                } else {
+                    channel.put(file.getInputStream(), originalFilename, ChannelSftp.APPEND);
+                }
+            } catch (Exception e) {
+                log.error(I18nMessageUtil.get("i18n.ssh_file_upload_exception.5c1c"), e);
+                return new JsonMessage<>(400, I18nMessageUtil.get("i18n.upload_failed.b019") + e.getMessage());
+            } finally {
+                JschUtil.close(channel);
+                JschUtil.close(session);
+            }
+            return JsonMessage.success(I18nMessageUtil.get("i18n.upload_success.a769"));
+        });
+    }
+*/
+    @RequestMapping(value = "upload", method = RequestMethod.POST, produces = MediaType.APPLICATION_JSON_VALUE)
+    @Feature(method = MethodFeature.UPLOAD)
+    public IJsonMessage<String> upload(@ValidatorItem String id,
+                                       @ValidatorItem String allowPathParent,
+                                       @ValidatorItem String nextPath,
+                                       String unzip,
+                                       MultipartFile file) {
+        return this.checkConfigPathChildren(id, allowPathParent, nextPath, (machineFtpModel, itemConfig) -> {
+
+            String remotePath = FileUtil.normalize(allowPathParent + StrUtil.SLASH + nextPath);
+            File filePath = null;
+            File tempUnzipPath = null;
+            try (Ftp ftp = new Ftp(machineFtpServer.toFtpConfig(machineFtpModel),
+                EnumUtil.fromString(FtpMode.class, machineFtpModel.getMode(), FtpMode.Active))) {
+
+                // 保存路径
+                File tempPath = serverConfig.getUserTempPath();
+                File savePath = FileUtil.file(tempPath, "ftp", machineFtpModel.getId());
+                FileUtil.mkdir(savePath);
+                String originalFilename = file.getOriginalFilename();
+                filePath = FileUtil.file(savePath, originalFilename);
+                //
+                if (Convert.toBool(unzip, false)) {
+                    String extName = FileUtil.extName(originalFilename);
+                    Assert.state(StrUtil.containsAnyIgnoreCase(extName, StringUtil.PACKAGE_EXT), I18nMessageUtil.get("i18n.file_type_not_supported2.d497") + extName);
+                    file.transferTo(filePath);
+                    // 解压
+                    tempUnzipPath = FileUtil.file(savePath, IdUtil.fastSimpleUUID());
+
+                    FileUtil.mkdir(tempUnzipPath);
+                    CompressionFileUtil.unCompress(filePath, tempUnzipPath);
+                    ftp.uploadFileOrDirectory(remotePath, tempUnzipPath);
+                } else {
+                    file.transferTo(filePath);
+                    ftp.uploadFileOrDirectory(remotePath, filePath);
+                }
+            } catch (Exception e) {
+                log.error("FTP上传文件异常", e);
+                return new JsonMessage<>(400, "FTP上传文件异常" + e.getMessage());
+            } finally {
+                CommandUtil.systemFastDel(filePath);
+                CommandUtil.systemFastDel(tempUnzipPath);
+            }
+            return JsonMessage.success(I18nMessageUtil.get("i18n.operation_succeeded.3313"));
+        });
+    }
+
+    /**
+     * @return json
+     * @api {post} new_file_folder.json ssh 中创建文件夹/文件
+     * @apiGroup ftp
+     * @apiUse defResultJson
+     * @apiParam {String} id ftp id
+     * @apiParam {String} path ftp 选择到目录
+     * @apiParam {String} name 文件名
+     * @apiParam {String} unFolder true/1 为文件夹,false/0 为文件
+     * @apiSuccess {JSON}  data
+     */
+    @RequestMapping(value = "new_file_folder.json", method = RequestMethod.POST, produces = MediaType.APPLICATION_JSON_VALUE)
+    @Feature(method = MethodFeature.UPLOAD)
+    public IJsonMessage<String> newFileFolder(String id,
+                                              @ValidatorItem String allowPathParent,
+                                              @ValidatorItem String nextPath,
+                                              @ValidatorItem String name, String unFolder) {
+        Assert.state(!StrUtil.contains(name, StrUtil.SLASH), I18nMessageUtil.get("i18n.file_name_error_message.7a25"));
+        return this.checkConfigPathChildren(id, allowPathParent, nextPath, (machineFtpModel, itemConfig) -> {
+
+            try (Ftp ftp = new Ftp(machineFtpServer.toFtpConfig(machineFtpModel),
+                EnumUtil.fromString(FtpMode.class, machineFtpModel.getMode(), FtpMode.Active))) {
+                String remotePath = FileUtil.normalize(allowPathParent + StrUtil.SLASH + nextPath + StrUtil.SLASH + name);
+
+                File filePath = null;
+                try {
+                    if (ftp.exist(remotePath)) {
+                        return new JsonMessage<>(400, "文件夹或文件已经存在");
+                    }
+                    if (Convert.toBool(unFolder, false)) {
+                        // 创建空文件到临时保存路径
+                        File tempPath = serverConfig.getUserTempPath();
+                        File savePath = FileUtil.file(tempPath, "ftp", machineFtpModel.getId());
+                        FileUtil.mkdir(savePath);
+                        filePath = FileUtil.file(savePath, name);
+                        FileUtil.touch(filePath);
+
+                        // 上传文件到ftp服务器 (需去掉目录中的文件名 防止创建文件同名目录)
+                        ftp.upload(StrUtil.removeAll(remotePath, StrUtil.SLASH + name), filePath);
+
+                    } else {
+                        // 目录
+                        try {
+                            if (ftp.mkdir(remotePath)) {
+                                // 创建成功
+                                return JsonMessage.success(I18nMessageUtil.get("i18n.operation_succeeded.3313"));
+                            }
+                        } catch (Exception e) {
+                            log.error("FTP创建文件夹异常", e);
+                            return new JsonMessage<>(500, "FTP创建文件夹异常" + e.getMessage());
+                        }
+                    }
+                    List<String> result = new ArrayList<>();
+                    return JsonMessage.success(I18nMessageUtil.get("i18n.operation_succeeded_with_details.c773") + CollUtil.join(result, StrUtil.LF));
+                } catch (Exception e) {
+                    throw new RuntimeException(e);
+                } finally {
+                    // 删除临时文件
+                    CommandUtil.systemFastDel(filePath);
+                }
+            } catch (IOException e) {
+                throw new RuntimeException(e);
+            }
+        });
+    }
+
+
+    public String getPermissionString(FTPFile file) {
+        // 如果权限信息无效,说明是 Windows FTP,返回占位符或空字符串
+        if (!file.hasPermission(FTPFile.USER_ACCESS, FTPFile.READ_PERMISSION)
+            && !file.hasPermission(FTPFile.GROUP_ACCESS, FTPFile.READ_PERMISSION)
+            && !file.hasPermission(FTPFile.WORLD_ACCESS, FTPFile.READ_PERMISSION)) {
+            return "---------"; // 或返回 "N/A"、空串 ""
+        }
+
+        // 否则按照 Unix 风格格式化
+        StringBuilder sb = new StringBuilder();
+        sb.append(file.isDirectory() ? 'd' : '-');
+        sb.append(file.hasPermission(FTPFile.USER_ACCESS, FTPFile.READ_PERMISSION) ? 'r' : '-');
+        sb.append(file.hasPermission(FTPFile.USER_ACCESS, FTPFile.WRITE_PERMISSION) ? 'w' : '-');
+        sb.append(file.hasPermission(FTPFile.USER_ACCESS, FTPFile.EXECUTE_PERMISSION) ? 'x' : '-');
+
+        sb.append(file.hasPermission(FTPFile.GROUP_ACCESS, FTPFile.READ_PERMISSION) ? 'r' : '-');
+        sb.append(file.hasPermission(FTPFile.GROUP_ACCESS, FTPFile.WRITE_PERMISSION) ? 'w' : '-');
+        sb.append(file.hasPermission(FTPFile.GROUP_ACCESS, FTPFile.EXECUTE_PERMISSION) ? 'x' : '-');
+
+        sb.append(file.hasPermission(FTPFile.WORLD_ACCESS, FTPFile.READ_PERMISSION) ? 'r' : '-');
+        sb.append(file.hasPermission(FTPFile.WORLD_ACCESS, FTPFile.WRITE_PERMISSION) ? 'w' : '-');
+        sb.append(file.hasPermission(FTPFile.WORLD_ACCESS, FTPFile.EXECUTE_PERMISSION) ? 'x' : '-');
+
+        return sb.toString();
+    }
+}

+ 332 - 5
modules/server/src/main/java/org/dromara/jpom/func/assets/controller/MachineFtpController.java

@@ -1,29 +1,67 @@
 package org.dromara.jpom.func.assets.controller;
 
+import cn.hutool.core.collection.CollUtil;
+import cn.hutool.core.convert.Convert;
+import cn.hutool.core.date.DatePattern;
+import cn.hutool.core.date.DateTime;
+import cn.hutool.core.io.CharsetDetector;
+import cn.hutool.core.io.FileUtil;
+import cn.hutool.core.io.IoUtil;
 import cn.hutool.core.lang.Opt;
+import cn.hutool.core.net.NetUtil;
+import cn.hutool.core.text.StrSplitter;
+import cn.hutool.core.text.csv.CsvData;
+import cn.hutool.core.text.csv.CsvReadConfig;
+import cn.hutool.core.text.csv.CsvReader;
+import cn.hutool.core.text.csv.CsvRow;
+import cn.hutool.core.text.csv.CsvUtil;
+import cn.hutool.core.text.csv.CsvWriter;
 import cn.hutool.core.util.EnumUtil;
+import cn.hutool.core.util.ObjectUtil;
 import cn.hutool.core.util.StrUtil;
 import cn.hutool.db.Entity;
 import cn.hutool.extra.ftp.Ftp;
 import cn.hutool.extra.ftp.FtpMode;
+import cn.hutool.extra.servlet.ServletUtil;
 import cn.keepbx.jpom.IJsonMessage;
 import cn.keepbx.jpom.model.JsonMessage;
+import java.io.File;
+import java.io.IOException;
+import java.io.Reader;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.HashSet;
+import java.util.Map;
+import java.util.Optional;
+import java.util.function.Function;
+import javax.servlet.http.HttpServletResponse;
 import lombok.extern.slf4j.Slf4j;
 import org.dromara.jpom.common.i18n.I18nMessageUtil;
+import org.dromara.jpom.common.interceptor.PermissionInterceptor;
 import org.dromara.jpom.common.validator.ValidatorItem;
 import org.dromara.jpom.common.validator.ValidatorRule;
+import org.dromara.jpom.configuration.AssetsConfig;
 import org.dromara.jpom.dialect.DialectUtil;
 import org.dromara.jpom.func.BaseGroupNameController;
 import org.dromara.jpom.func.assets.model.MachineFtpModel;
+import org.dromara.jpom.func.assets.model.MachineSshModel;
 import org.dromara.jpom.func.assets.server.MachineFtpServer;
 import org.dromara.jpom.model.PageResultDto;
 import org.dromara.jpom.model.data.AgentWhitelist;
+import org.dromara.jpom.model.data.FtpModel;
+import org.dromara.jpom.model.data.SshModel;
+import org.dromara.jpom.model.data.WorkspaceModel;
+import org.dromara.jpom.model.user.UserModel;
 import org.dromara.jpom.permission.ClassFeature;
 import org.dromara.jpom.permission.Feature;
 import org.dromara.jpom.permission.MethodFeature;
 import org.dromara.jpom.permission.SystemPermission;
+import org.dromara.jpom.service.node.ftp.FtpService;
+import org.dromara.jpom.service.system.WorkspaceService;
+import org.dromara.jpom.system.ServerConfig;
 import org.springframework.http.MediaType;
 import org.springframework.util.Assert;
+import org.springframework.web.bind.annotation.GetMapping;
 import org.springframework.web.bind.annotation.PostMapping;
 import org.springframework.web.bind.annotation.RequestMapping;
 import org.springframework.web.bind.annotation.RestController;
@@ -31,6 +69,7 @@ import org.springframework.web.bind.annotation.RestController;
 import javax.servlet.http.HttpServletRequest;
 import java.nio.charset.Charset;
 import java.util.List;
+import org.springframework.web.multipart.MultipartFile;
 
 /**
  * @author bwcx_jzy
@@ -44,10 +83,18 @@ import java.util.List;
 public class MachineFtpController extends BaseGroupNameController {
 
     private final MachineFtpServer machineFtpServer;
+    private final AssetsConfig.FtpConfig ftpConfig;
+    private final WorkspaceService workspaceService;
+    private final FtpService ftpService;
+    private final ServerConfig serverConfig;
 
-    public MachineFtpController(MachineFtpServer machineFtpServer) {
+    public MachineFtpController(MachineFtpServer machineFtpServer, AssetsConfig assetsConfig, WorkspaceService workspaceService, FtpService ftpService, ServerConfig serverConfig) {
         super(machineFtpServer);
         this.machineFtpServer = machineFtpServer;
+        this.ftpConfig = assetsConfig.getFtp();
+        this.workspaceService = workspaceService;
+        this.ftpService = ftpService;
+        this.serverConfig = serverConfig;
     }
 
     @PostMapping(value = "list-data", produces = MediaType.APPLICATION_JSON_VALUE)
@@ -57,11 +104,27 @@ public class MachineFtpController extends BaseGroupNameController {
         return JsonMessage.success("", pageResultDto);
     }
 
+    @Override
+    @GetMapping(value = "list-group", produces = MediaType.APPLICATION_JSON_VALUE)
+    @Feature(method = MethodFeature.LIST)
+    public IJsonMessage<Collection<String>> listGroup() {
+        Collection<String> list = dbService.listGroupName();
+        // 合并配置禁用分组
+        List<String> monitorGroupName = ftpConfig.getDisableMonitorGroupName();
+        if (monitorGroupName != null) {
+            list.addAll(monitorGroupName);
+            //
+            list.remove("*");
+            list = new HashSet<>(list);
+        }
+        return JsonMessage.success("", list);
+    }
+
     /**
      * 编辑
      *
      * @param name               名称
-     * @param host               端口
+     * @param host               主机
      * @param user               用户名
      * @param password           密码
      * @param serverLanguageCode 服务器语言
@@ -86,13 +149,20 @@ public class MachineFtpController extends BaseGroupNameController {
                                      String systemKey,
                                      String mode,
                                      String groupName) {
+        boolean add = StrUtil.isEmpty(id);
+        if (add) {
+            Assert.hasText(password, I18nMessageUtil.get("i18n.login_password_required.9605"));
+        } else {
+            boolean exists = machineFtpServer.exists(new MachineFtpModel(id));
+            Assert.state(exists, "不存在对应ftp");
+        }
 
         MachineFtpModel model = new MachineFtpModel();
         model.setId(id);
         model.setGroupName(groupName);
         model.setHost(host);
         model.setServerLanguageCode(serverLanguageCode);
-        model.setMode(EnumUtil.fromString(FtpMode.class, mode, FtpMode.Active));
+        model.setMode(mode);
         model.setSystemKey(systemKey);
         // 如果密码传递不为空就设置值 因为上面已经判断了只有修改的情况下 password 才可能为空
         Opt.ofBlankAble(password).ifPresent(model::setPassword);
@@ -117,17 +187,274 @@ public class MachineFtpController extends BaseGroupNameController {
         Opt.ofBlankAble(id).ifPresent(s -> entity.set("id", StrUtil.format(" <> {}", s)));
         boolean exists = machineFtpServer.exists(entity);
         Assert.state(!exists, "对应的FTP已经存在啦");
+
         // 测试连接
-        try (Ftp ftp = new Ftp(machineFtpServer.toFtpConfig(model), model.getMode())) {
+        try (Ftp ftp = new Ftp(machineFtpServer.toFtpConfig(model),
+            EnumUtil.fromString(FtpMode.class, mode, FtpMode.Active))) {
             ftp.pwd();
+            List<String> ls = ftp.ls(".");
+            System.out.println(ls);
         } catch (Exception e) {
             log.error("连接FTP失败", e);
             return new JsonMessage<>(500, "连接FTP失败:" + e.getMessage());
         }
 
         model.setStatus(1);
-        boolean add = StrUtil.isEmpty(id);
         int i = add ? machineFtpServer.insert(model) : machineFtpServer.updateById(model);
         return JsonMessage.success(I18nMessageUtil.get("i18n.operation_succeeded.3313"));
     }
+
+    @GetMapping(value = "list-workspace-ftp", produces = MediaType.APPLICATION_JSON_VALUE)
+    @Feature(method = MethodFeature.LIST)
+    public IJsonMessage<List<FtpModel>> listWorkspaceFtp(@ValidatorItem String id) {
+        MachineFtpModel machineFtpModel = machineFtpServer.getByKey(id);
+        Assert.notNull(machineFtpModel, I18nMessageUtil.get("i18n.no_machine.89ed"));
+        FtpModel ftpModel = new FtpModel();
+        ftpModel.setMachineFtpId(id);
+        List<FtpModel> modelList = ftpService.listByBean(ftpModel);
+        modelList = Optional.ofNullable(modelList).orElseGet(ArrayList::new);
+        for (FtpModel model : modelList) {
+            model.setWorkspace(workspaceService.getByKey(model.getWorkspaceId()));
+        }
+        return JsonMessage.success("", modelList);
+    }
+
+    /**
+     * 将 ftp 分配到指定工作空间
+     *
+     * @param ids         ftp id
+     * @param workspaceId 工作空间id
+     * @return json
+     */
+    @PostMapping(value = "distribute", produces = MediaType.APPLICATION_JSON_VALUE)
+    @Feature(method = MethodFeature.EDIT)
+    public IJsonMessage<String> distribute(@ValidatorItem String ids, @ValidatorItem String workspaceId) {
+        List<String> list = StrUtil.splitTrim(ids, StrUtil.COMMA);
+        for (String id : list) {
+            MachineFtpModel machineFtpModel = machineFtpServer.getByKey(id);
+            Assert.notNull(machineFtpModel, "没有对应的FTP");
+            boolean exists = workspaceService.exists(new WorkspaceModel(workspaceId));
+            Assert.state(exists, I18nMessageUtil.get("i18n.workspace_not_exist.a6fd"));
+            if (!ftpService.existsFtp2(workspaceId, id)) {
+                ftpService.insert(machineFtpModel, workspaceId);
+            }
+        }
+
+        return JsonMessage.success(I18nMessageUtil.get("i18n.operation_succeeded.3313"));
+    }
+
+
+    /**
+     * 保存工作空间配置
+     *
+     * @param fileDirs 文件夹
+     * @param id       ID
+     * @return json
+     */
+    @PostMapping(value = "save-workspace-config", produces = MediaType.APPLICATION_JSON_VALUE)
+    @Feature(method = MethodFeature.EDIT)
+    public IJsonMessage<String> saveWorkspaceConfig(
+        String fileDirs,
+        @ValidatorItem String id,
+        String allowEditSuffix) {
+        FtpModel ftpModel = new FtpModel(id);
+        // 目录
+        if (StrUtil.isEmpty(fileDirs)) {
+            ftpModel.fileDirs(null);
+        } else {
+            List<String> list = StrSplitter.splitTrim(fileDirs, StrUtil.LF, true);
+            /*for (String s : list) {
+                String normalize = FileUtil.normalize(s + StrUtil.SLASH);
+                int count = StrUtil.count(normalize, StrUtil.SLASH);
+                Assert.state(count >= 2, I18nMessageUtil.get("i18n.ssh_authorization_directory_cannot_be_root.8125"));
+            }*/
+            //
+            UserModel userModel = getUser();
+            Assert.state(!userModel.isDemoUser(), PermissionInterceptor.DEMO_TIP);
+            ftpModel.fileDirs(list);
+        }
+        // 获取允许编辑的后缀
+        List<String> allowEditSuffixList = AgentWhitelist.parseToList(allowEditSuffix, I18nMessageUtil.get("i18n.suffix_cannot_be_empty.ec72"));
+        ftpModel.allowEditSuffix(allowEditSuffixList);
+        ftpService.updateById(ftpModel);
+        return JsonMessage.success(I18nMessageUtil.get("i18n.operation_succeeded.3313"));
+    }
+
+    /**
+     * edit
+     *
+     * @param id ssh id
+     * @return json
+     */
+    @PostMapping(value = "rest-hide-field", produces = MediaType.APPLICATION_JSON_VALUE)
+    @Feature(method = MethodFeature.EDIT)
+    public IJsonMessage<String> restHideField(@ValidatorItem String id) {
+        MachineFtpModel machineFtpModel = new MachineFtpModel();
+        machineFtpModel.setId(id);
+        machineFtpModel.setPassword(StrUtil.EMPTY);
+        machineFtpServer.updateById(machineFtpModel);
+        return new JsonMessage<>(200, I18nMessageUtil.get("i18n.operation_succeeded.3313"));
+    }
+
+    /**
+     * 下载导入模板
+     */
+    @GetMapping(value = "import-template", produces = MediaType.APPLICATION_JSON_VALUE)
+    @Feature(method = MethodFeature.LIST)
+    public void importTemplate(HttpServletResponse response) throws IOException {
+        String fileName = "ftp导入模板.csv";
+        this.setApplicationHeader(response, fileName);
+        //
+        CsvWriter writer = CsvUtil.getWriter(response.getWriter());
+        writer.writeLine("name", "groupName", "host", "port", "user", "password", "serverLanguageCode", "systemKey", "charset", "mode", "timeout");
+        writer.flush();
+    }
+
+    /**
+     * 导出数据
+     */
+    @GetMapping(value = "export-data", produces = MediaType.APPLICATION_JSON_VALUE)
+    @Feature(method = MethodFeature.DOWNLOAD)
+    public void exportData(HttpServletResponse response, HttpServletRequest request) throws IOException {
+        String prefix = "导出的 ftp 数据";
+        String fileName = prefix + DateTime.now().toString(DatePattern.NORM_DATE_FORMAT) + ".csv";
+        this.setApplicationHeader(response, fileName);
+        //
+        CsvWriter writer = CsvUtil.getWriter(response.getWriter());
+        int pageInt = 0;
+        writer.writeLine("name", "groupName", "host", "port", "user", "password", "serverLanguageCode", "systemKey", "charset", "mode", "timeout");
+        while (true) {
+            Map<String, String> paramMap = ServletUtil.getParamMap(request);
+            paramMap.remove("workspaceId");
+            // 下一页
+            paramMap.put("page", String.valueOf(++pageInt));
+            PageResultDto<MachineFtpModel> listPage = machineFtpServer.listPage(paramMap, false);
+            if (listPage.isEmpty()) {
+                break;
+            }
+            listPage.getResult()
+                .stream()
+                .map((Function<MachineFtpModel, List<Object>>) machineSshModel -> CollUtil.newArrayList(
+                    machineSshModel.getName(),
+                    machineSshModel.getGroupName(),
+                    machineSshModel.getHost(),
+                    machineSshModel.getPort(),
+                    machineSshModel.getUser(),
+                    machineSshModel.getPassword(),
+                    machineSshModel.getCharset(),
+                    machineSshModel.getMode(),
+                    machineSshModel.getTimeout()
+                ))
+                .map(objects -> objects.stream().map(StrUtil::toStringOrNull).toArray(String[]::new))
+                .forEach(writer::writeLine);
+            if (ObjectUtil.equal(listPage.getPage(), listPage.getTotalPage())) {
+                // 最后一页
+                break;
+            }
+        }
+        writer.flush();
+    }
+
+    /**
+     * 导入数据
+     *
+     * @return json
+     */
+//            writer.writeLine("name", "groupName", "host", "port", "user", "password", "serverLanguageCode", "systemKey", "charset", "mode", "timeout");
+
+    @PostMapping(value = "import-data", produces = MediaType.APPLICATION_JSON_VALUE)
+    @Feature(method = MethodFeature.UPLOAD)
+    public IJsonMessage<String> importData(MultipartFile file) throws IOException {
+        Assert.notNull(file, I18nMessageUtil.get("i18n.no_uploaded_file.07ef"));
+        String originalFilename = file.getOriginalFilename();
+        String extName = FileUtil.extName(originalFilename);
+        boolean csv = StrUtil.endWithIgnoreCase(extName, "csv");
+        Assert.state(csv, I18nMessageUtil.get("i18n.disallowed_file_format.d6e4"));
+        assert originalFilename != null;
+        File csvFile = FileUtil.file(serverConfig.getUserTempPath(), originalFilename);
+        int addCount = 0, updateCount = 0;
+        Charset fileCharset;
+        try {
+            file.transferTo(csvFile);
+            fileCharset = CharsetDetector.detect(csvFile);
+            Reader bomReader = FileUtil.getReader(csvFile, fileCharset);
+            CsvReadConfig csvReadConfig = CsvReadConfig.defaultConfig();
+            csvReadConfig.setHeaderLineNo(0);
+            CsvReader reader = CsvUtil.getReader(bomReader, csvReadConfig);
+            CsvData csvData;
+            try {
+                csvData = reader.read();
+            } catch (Exception e) {
+                log.error(I18nMessageUtil.get("i18n.parse_csv_exception.885e"), e);
+                return new JsonMessage<>(405, I18nMessageUtil.get("i18n.parse_file_exception.374d") + e.getMessage());
+            } finally {
+                IoUtil.close(reader);
+            }
+            List<CsvRow> rows = csvData.getRows();
+            Assert.notEmpty(rows, I18nMessageUtil.get("i18n.no_data.55a2"));
+
+            for (int i = 0; i < rows.size(); i++) {
+                CsvRow csvRow = rows.get(i);
+                String name = csvRow.getByName("name");
+                int finalI = i;
+                Assert.hasText(name, () -> StrUtil.format(I18nMessageUtil.get("i18n.name_field_required.e0c5"), finalI + 1));
+                String groupName = csvRow.getByName("groupName");
+                String host = csvRow.getByName("host");
+                Assert.hasText(host, () -> StrUtil.format(I18nMessageUtil.get("i18n.host_field_required.5c36"), finalI + 1));
+                Integer port = Convert.toInt(csvRow.getByName("port"));
+                Assert.state(port != null && NetUtil.isValidPort(port), () -> StrUtil.format(I18nMessageUtil.get("i18n.port_field_required_or_incorrect.8426"), finalI + 1));
+                String user = csvRow.getByName("user");
+                Assert.hasText(host, () -> StrUtil.format(I18nMessageUtil.get("i18n.user_field_required.8732"), finalI + 1));
+                String password = csvRow.getByName("password");
+                String charset = csvRow.getByName("charset");
+                //
+                String mode = csvRow.getByName("mode");
+                FtpMode ftpMode = EnumUtil.fromString(FtpMode.class, mode, FtpMode.Active);
+                mode = ftpMode.name();
+                String serverLanguageCode = csvRow.getByName("serverLanguageCode");
+                String systemKey = csvRow.getByName("systemKey");
+
+                Integer timeout = Convert.toInt(csvRow.getByName("timeout"));
+                //
+                MachineFtpModel where = new MachineFtpModel();
+                where.setHost(host);
+                where.setUser(user);
+                where.setPort(port);
+                where.setMode(mode);
+                MachineFtpModel machineFtpModel = machineFtpServer.queryByBean(where);
+                if (machineFtpModel == null) {
+                    // 添加
+                    where.setName(name);
+                    where.setGroupName(groupName);
+                    where.setPassword(password);
+                    where.setMode(mode);
+                    where.setTimeout(timeout);
+                    where.setCharset(charset);
+                    where.setServerLanguageCode(serverLanguageCode);
+                    where.setSystemKey(systemKey);
+                    machineFtpServer.insert(where);
+                    addCount++;
+                } else {
+                    MachineFtpModel update = new MachineFtpModel();
+                    update.setId(machineFtpModel.getId());
+                    update.setName(name);
+                    update.setGroupName(groupName);
+                    update.setPassword(password);
+                    update.setMode(mode);
+                    update.setTimeout(timeout);
+                    update.setCharset(charset);
+                    where.setServerLanguageCode(serverLanguageCode);
+                    where.setSystemKey(systemKey);
+                    machineFtpServer.updateById(update);
+                    updateCount++;
+                }
+            }
+        } finally {
+            FileUtil.del(csvFile);
+        }
+        String fileCharsetStr = Optional.ofNullable(fileCharset).map(Charset::name).orElse(StrUtil.EMPTY);
+        return JsonMessage.success(I18nMessageUtil.get("i18n.import_success_with_details.a4a0"), fileCharsetStr, addCount, updateCount);
+    }
+
+
 }

+ 74 - 0
modules/server/src/main/java/org/dromara/jpom/func/assets/controller/MachineFtpFileController.java

@@ -0,0 +1,74 @@
+/*
+ * Copyright (c) 2019 Of Him Code Technology Studio
+ * Jpom is licensed under Mulan PSL v2.
+ * You can use this software according to the terms and conditions of the Mulan PSL v2.
+ * You may obtain a copy of Mulan PSL v2 at:
+ * 			http://license.coscl.org.cn/MulanPSL2
+ * THIS SOFTWARE IS PROVIDED ON AN "AS IS" BASIS, WITHOUT WARRANTIES OF ANY KIND, EITHER EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO NON-INFRINGEMENT, MERCHANTABILITY OR FIT FOR A PARTICULAR PURPOSE.
+ * See the Mulan PSL v2 for more details.
+ */
+
+package org.dromara.jpom.func.assets.controller;
+
+import cn.hutool.core.collection.CollUtil;
+import cn.hutool.core.lang.Opt;
+import cn.hutool.core.util.StrUtil;
+import java.util.List;
+import java.util.function.BiFunction;
+import lombok.extern.slf4j.Slf4j;
+import org.dromara.jpom.func.assets.model.MachineFtpModel;
+import org.dromara.jpom.permission.ClassFeature;
+import org.dromara.jpom.permission.Feature;
+import org.dromara.jpom.permission.SystemPermission;
+import org.dromara.jpom.util.FileUtils;
+import org.dromara.jpom.util.StringUtil;
+import org.springframework.util.Assert;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+/**
+ * @author wxyShine
+ * @since 2025/05/28
+ */
+@RestController
+@RequestMapping(value = "/system/assets/ftp-file")
+@Feature(cls = ClassFeature.FTP_FILE)
+@Slf4j
+@SystemPermission
+public class MachineFtpFileController extends BaseFtpFileController {
+    @Override
+    protected <T> T checkConfigPath(String id, BiFunction<MachineFtpModel, ItemConfig, T> function) {
+        MachineFtpModel machineFtpModel = machineFtpServer.getByKey(id, false);
+        Assert.notNull(machineFtpModel, "没有对应的Ftp");
+        return function.apply(machineFtpModel, new ItemConfig() {
+            @Override
+            public List<String> allowEditSuffix() {
+                return StringUtil.jsonConvertArray(machineFtpModel.getAllowEditSuffix(), String.class);
+            }
+
+            @Override
+            public List<String> fileDirs() {
+                return CollUtil.newArrayList(StrUtil.SLASH);
+            }
+        });
+    }
+
+    @Override
+    protected <T> T checkConfigPathChildren(String id, String path, String children, BiFunction<MachineFtpModel, ItemConfig, T> function) {
+        FileUtils.checkSlip(path);
+        Opt.ofBlankAble(children).ifPresent(FileUtils::checkSlip);
+        //
+        MachineFtpModel machineFtpModel = machineFtpServer.getByKey(id, false);
+        return function.apply(machineFtpModel, new ItemConfig() {
+            @Override
+            public List<String> allowEditSuffix() {
+                return StringUtil.jsonConvertArray(machineFtpModel.getAllowEditSuffix(), String.class);
+            }
+
+            @Override
+            public List<String> fileDirs() {
+                return CollUtil.newArrayList(StrUtil.SLASH);
+            }
+        });
+    }
+}

+ 1 - 1
modules/server/src/main/java/org/dromara/jpom/func/assets/controller/MachineSshController.java

@@ -155,7 +155,7 @@ public class MachineSshController extends BaseGroupNameController {
      * 编辑
      *
      * @param name        名称
-     * @param host        端口
+     * @param host        主机
      * @param user        用户名
      * @param password    密码
      * @param connectType 连接方式

+ 2 - 2
modules/server/src/main/java/org/dromara/jpom/func/assets/model/MachineFtpModel.java

@@ -69,9 +69,9 @@ public class MachineFtpModel extends BaseGroupNameModel {
     /**
      * 模式
      */
-    private FtpMode mode;
+    private String mode;
     /**
-     * ssh连接状态
+     * ftp连接状态
      * <p>
      * 状态{0,无法连接,1 正常,2 禁用监控}
      */

+ 5 - 1
modules/server/src/main/java/org/dromara/jpom/func/assets/server/MachineFtpServer.java

@@ -2,12 +2,14 @@ package org.dromara.jpom.func.assets.server;
 
 import cn.hutool.core.collection.CollUtil;
 import cn.hutool.core.lang.Opt;
+import cn.hutool.core.util.EnumUtil;
 import cn.hutool.core.util.ObjectUtil;
 import cn.hutool.core.util.StrUtil;
 import cn.hutool.cron.task.Task;
 import cn.hutool.db.Entity;
 import cn.hutool.extra.ftp.Ftp;
 import cn.hutool.extra.ftp.FtpConfig;
+import cn.hutool.extra.ftp.FtpMode;
 import cn.keepbx.jpom.event.IAsyncLoad;
 import lombok.Lombok;
 import lombok.extern.slf4j.Slf4j;
@@ -130,7 +132,9 @@ public class MachineFtpServer extends BaseDbService<MachineFtpModel> implements
             this.updateStatus(machineFtpModel.getId(), 2, I18nMessageUtil.get("i18n.disable_monitoring.4615"));
             return;
         }
-        try (Ftp ftp = new Ftp(this.toFtpConfig(machineFtpModel), machineFtpModel.getMode())) {
+
+        try (Ftp ftp = new Ftp(this.toFtpConfig(machineFtpModel),
+            EnumUtil.fromString(FtpMode.class, machineFtpModel.getMode(), FtpMode.Active))) {
             ftp.pwd();
             //
             this.updateStatus(machineFtpModel.getId(), 1, "");

+ 125 - 0
modules/server/src/main/java/org/dromara/jpom/model/data/FtpModel.java

@@ -0,0 +1,125 @@
+/*
+ * Copyright (c) 2019 Of Him Code Technology Studio
+ * Jpom is licensed under Mulan PSL v2.
+ * You can use this software according to the terms and conditions of the Mulan PSL v2.
+ * You may obtain a copy of Mulan PSL v2 at:
+ * 			http://license.coscl.org.cn/MulanPSL2
+ * THIS SOFTWARE IS PROVIDED ON AN "AS IS" BASIS, WITHOUT WARRANTIES OF ANY KIND, EITHER EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO NON-INFRINGEMENT, MERCHANTABILITY OR FIT FOR A PARTICULAR PURPOSE.
+ * See the Mulan PSL v2 for more details.
+ */
+
+package org.dromara.jpom.model.data;
+
+import cn.hutool.core.annotation.PropIgnore;
+import cn.hutool.core.io.FileUtil;
+import cn.hutool.core.util.StrUtil;
+import com.alibaba.fastjson2.JSONArray;
+import java.util.List;
+import java.util.Optional;
+import java.util.stream.Collectors;
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+import lombok.NoArgsConstructor;
+import org.dromara.jpom.db.TableName;
+import org.dromara.jpom.func.assets.controller.BaseFtpFileController;
+import org.dromara.jpom.func.assets.model.MachineFtpModel;
+import org.dromara.jpom.model.BaseGroupModel;
+import org.dromara.jpom.util.StringUtil;
+
+
+/**
+ * ftp信息
+ *
+ * @author wxyShine
+ * @since 2025/05/29
+ */
+@TableName(value = "FTP_INFO",
+    nameKey = "ftp信息表")
+@Data
+@EqualsAndHashCode(callSuper = true)
+@NoArgsConstructor
+public class FtpModel extends BaseGroupModel implements BaseFtpFileController.ItemConfig {
+
+    private String name;
+    @Deprecated
+    private String host;
+    @Deprecated
+    private Integer port;
+    @Deprecated
+    private String user;
+    @Deprecated
+    private String password;
+    /**
+     * 编码格式
+     */
+    @Deprecated
+    private String charset;
+
+    /**
+     * 文件目录
+     */
+    private String fileDirs;
+
+    /**
+     * 允许编辑的后缀文件
+     */
+    private String allowEditSuffix;
+    /**
+     * 节点超时时间
+     */
+    @Deprecated
+    private Integer timeout;
+
+    /**
+     * ftp id
+     */
+    private String machineFtpId;
+
+    @PropIgnore
+    private MachineFtpModel machineFtp;
+
+
+    @PropIgnore
+    private WorkspaceModel workspace;
+
+    public FtpModel(String id) {
+        this.setId(id);
+    }
+
+
+    @Override
+    public List<String> fileDirs() {
+        List<String> strings = StringUtil.jsonConvertArray(this.fileDirs, String.class);
+        return Optional.ofNullable(strings)
+            .map(strings1 -> strings1.stream()
+                .map(s -> FileUtil.normalize(StrUtil.SLASH + s + StrUtil.SLASH))
+                .collect(Collectors.toList()))
+            .orElse(null);
+    }
+
+    public void fileDirs(List<String> fileDirs) {
+        if (fileDirs != null) {
+            for (int i = fileDirs.size() - 1; i >= 0; i--) {
+                String s = fileDirs.get(i);
+                fileDirs.set(i, FileUtil.normalize(s));
+            }
+            this.fileDirs = JSONArray.toJSONString(fileDirs);
+        } else {
+            this.fileDirs = StrUtil.EMPTY;
+        }
+    }
+
+
+    @Override
+    public List<String> allowEditSuffix() {
+        return StringUtil.jsonConvertArray(this.allowEditSuffix, String.class);
+    }
+
+    public void allowEditSuffix(List<String> allowEditSuffix) {
+        if (allowEditSuffix == null) {
+            this.allowEditSuffix = null;
+        } else {
+            this.allowEditSuffix = JSONArray.toJSONString(allowEditSuffix);
+        }
+    }
+}

+ 18 - 4
modules/server/src/main/java/org/dromara/jpom/permission/ClassFeature.java

@@ -9,16 +9,27 @@
  */
 package org.dromara.jpom.permission;
 
+import java.util.function.Supplier;
 import lombok.Getter;
 import org.dromara.jpom.common.i18n.I18nMessageUtil;
-import org.dromara.jpom.func.assets.server.*;
+import org.dromara.jpom.func.assets.server.MachineDockerServer;
+import org.dromara.jpom.func.assets.server.MachineFtpServer;
+import org.dromara.jpom.func.assets.server.MachineNodeServer;
+import org.dromara.jpom.func.assets.server.MachineSshServer;
+import org.dromara.jpom.func.assets.server.ScriptLibraryServer;
 import org.dromara.jpom.func.cert.service.CertificateInfoService;
 import org.dromara.jpom.func.files.service.FileReleaseTaskService;
 import org.dromara.jpom.func.files.service.FileStorageService;
 import org.dromara.jpom.func.files.service.StaticFileStorageService;
 import org.dromara.jpom.func.system.service.ClusterInfoService;
 import org.dromara.jpom.func.user.server.UserLoginLogServer;
-import org.dromara.jpom.service.dblog.*;
+import org.dromara.jpom.service.dblog.BackupInfoService;
+import org.dromara.jpom.service.dblog.BuildInfoService;
+import org.dromara.jpom.service.dblog.DbBuildHistoryLogService;
+import org.dromara.jpom.service.dblog.DbMonitorNotifyLogService;
+import org.dromara.jpom.service.dblog.DbUserOperateLogService;
+import org.dromara.jpom.service.dblog.RepositoryService;
+import org.dromara.jpom.service.dblog.SshTerminalExecuteLogService;
 import org.dromara.jpom.service.docker.DockerInfoService;
 import org.dromara.jpom.service.docker.DockerSwarmInfoService;
 import org.dromara.jpom.service.h2db.BaseDbService;
@@ -26,6 +37,7 @@ import org.dromara.jpom.service.monitor.MonitorService;
 import org.dromara.jpom.service.monitor.MonitorUserOptService;
 import org.dromara.jpom.service.node.NodeService;
 import org.dromara.jpom.service.node.ProjectInfoCacheService;
+import org.dromara.jpom.service.node.ftp.FtpService;
 import org.dromara.jpom.service.node.script.NodeScriptExecuteLogServer;
 import org.dromara.jpom.service.node.script.NodeScriptServer;
 import org.dromara.jpom.service.node.ssh.CommandExecLogService;
@@ -41,8 +53,6 @@ import org.dromara.jpom.service.system.WorkspaceService;
 import org.dromara.jpom.service.user.UserPermissionGroupServer;
 import org.dromara.jpom.service.user.UserService;
 
-import java.util.function.Supplier;
-
 /**
  * 功能模块
  *
@@ -65,6 +75,8 @@ public enum ClassFeature {
     SSH_TERMINAL_LOG(() -> I18nMessageUtil.get("i18n.ssh_terminal_log.775f"), SshTerminalExecuteLogService.class),
     SSH_COMMAND(() -> I18nMessageUtil.get("i18n.ssh_command_management.c40a"), SshCommandService.class),
     SSH_COMMAND_LOG(() -> I18nMessageUtil.get("i18n.ssh_command_log.7fd1"), CommandExecLogService.class),
+    FTP(() -> "FTP管理", SshService.class),
+    FTP_FILE(() -> "FTP文件管理", FtpService.class),
     OUTGIVING(() -> I18nMessageUtil.get("i18n.distribute_management.3a2d"), OutGivingServer.class),
     LOG_READ(() -> I18nMessageUtil.get("i18n.log_reading.a4c8"), LogReadServer.class),
     OUTGIVING_LOG(() -> I18nMessageUtil.get("i18n.distribute_log.c612"), DbOutGivingLogService.class),
@@ -74,6 +86,8 @@ public enum ClassFeature {
     OPT_MONITOR(() -> I18nMessageUtil.get("i18n.operation_monitoring.0cd5"), MonitorUserOptService.class),
     DOCKER(() -> I18nMessageUtil.get("i18n.docker_management.e7e5"), DockerInfoService.class),
     DOCKER_SWARM(() -> I18nMessageUtil.get("i18n.container_cluster.a5b4"), DockerSwarmInfoService.class),
+
+
     /**
      * ssh
      */

+ 239 - 0
modules/server/src/main/java/org/dromara/jpom/service/node/ftp/FtpService.java

@@ -0,0 +1,239 @@
+/*
+ * Copyright (c) 2019 Of Him Code Technology Studio
+ * Jpom is licensed under Mulan PSL v2.
+ * You can use this software according to the terms and conditions of the Mulan PSL v2.
+ * You may obtain a copy of Mulan PSL v2 at:
+ * 			http://license.coscl.org.cn/MulanPSL2
+ * THIS SOFTWARE IS PROVIDED ON AN "AS IS" BASIS, WITHOUT WARRANTIES OF ANY KIND, EITHER EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO NON-INFRINGEMENT, MERCHANTABILITY OR FIT FOR A PARTICULAR PURPOSE.
+ * See the Mulan PSL v2 for more details.
+ */
+
+package org.dromara.jpom.service.node.ftp;
+
+import cn.hutool.core.io.FileUtil;
+import cn.hutool.core.util.NumberUtil;
+import cn.hutool.core.util.StrUtil;
+import com.jcraft.jsch.JSch;
+import com.jcraft.jsch.SftpException;
+import java.io.IOException;
+import java.util.Set;
+import java.util.concurrent.ConcurrentHashMap;
+import javax.annotation.Resource;
+import lombok.extern.slf4j.Slf4j;
+import org.dromara.jpom.common.ServerConst;
+import org.dromara.jpom.common.i18n.I18nMessageUtil;
+import org.dromara.jpom.configuration.BuildExtConfig;
+import org.dromara.jpom.func.assets.model.MachineFtpModel;
+import org.dromara.jpom.func.assets.server.MachineFtpServer;
+import org.dromara.jpom.model.data.FtpModel;
+import org.dromara.jpom.plugins.JschLogger;
+import org.dromara.jpom.service.h2db.BaseWorkspaceService;
+import org.dromara.jpom.util.LogRecorder;
+import org.dromara.jpom.util.MySftp;
+import org.springframework.context.annotation.Lazy;
+import org.springframework.stereotype.Service;
+import org.springframework.util.Assert;
+
+
+/**
+ * @author wxyShine
+ * @since 2025/05/29
+ */
+@Service
+@Slf4j
+public class FtpService extends BaseWorkspaceService<FtpModel> {
+
+    @Resource
+    @Lazy
+    private MachineFtpServer machineFtpServer;
+    private final BuildExtConfig buildExtConfig;
+
+    public FtpService(BuildExtConfig buildExtConfig) {
+        this.buildExtConfig = buildExtConfig;
+        JSch.setLogger(JschLogger.LOGGER);
+    }
+
+    @Override
+    protected void fillSelectResult(FtpModel data) {
+        if (data == null) {
+            return;
+        }
+        if (!StrUtil.startWithIgnoreCase(data.getPassword(), ServerConst.REF_WORKSPACE_ENV)) {
+            // 隐藏密码字段
+            data.setPassword(null);
+        }
+    }
+
+    @Override
+    protected void fillInsert(FtpModel ftpModel) {
+        super.fillInsert(ftpModel);
+        ftpModel.setHost(StrUtil.EMPTY);
+        ftpModel.setUser(StrUtil.EMPTY);
+        ftpModel.setPort(0);
+    }
+
+
+    /**
+     * 获取 ftp 配置对象
+     *
+     * @param ftpModel ftpModel
+     * @return session
+     */
+    public MachineFtpModel getMachineSshModel(FtpModel ftpModel) {
+        MachineFtpModel ftpModel1 = machineFtpServer.getByKey(ftpModel.getMachineFtpId(), false);
+        Assert.notNull(ftpModel1, I18nMessageUtil.get("i18n.asset_ssh_not_exist.cd43"));
+        return ftpModel1;
+    }
+
+
+    /**
+     * 上传文件
+     *
+     * @param machineSshModel ssh
+     * @param remotePath      远程路径
+     * @param desc            文件夹或者文件
+     */
+/*    public void uploadDir(MachineSshModel machineSshModel, String remotePath, File desc) {
+        Session session = null;
+        ChannelSftp channel = null;
+        // MachineSshModel machineSshModel = this.getMachineSshModel(sshModel);
+        try {
+            session = this.getSessionByModel(machineSshModel);
+            channel = (ChannelSftp) JschUtil.openChannel(session, ChannelType.SFTP);
+            try (Sftp sftp = new Sftp(channel, machineSshModel.charset(), machineSshModel.timeout())) {
+                sftp.syncUpload(desc, remotePath);
+            }
+            //uploadDir(channel, remotePath, desc, sshModel.getCharsetT());
+        } finally {
+            JschUtil.close(channel);
+            JschUtil.close(session);
+        }
+    }*/
+
+    /**
+     * 下载文件
+     *
+     * @param ftpModel   实体
+     * @param remoteFile 远程文件
+     * @param save       文件对象
+     * @throws IOException   io
+     * @throws SftpException sftp
+     */
+  /*  public void download(FtpModel ftpModel, String remoteFile, File save) throws IOException, SftpException {
+        Session session = null;
+        ChannelSftp channel = null;
+        OutputStream output = null;
+        try (Ftp ftp = new Ftp(machineFtpServer.toFtpConfig(machineFtpModel), ftpMode)) {
+            session = this.getSessionByModel(ftpModel);
+            channel = (ChannelSftp) JschUtil.openChannel(session, ChannelType.SFTP);
+            output = Files.newOutputStream(save.toPath());
+            channel.get(remoteFile, output);
+        } finally {
+            IoUtil.close(output);
+            JschUtil.close(channel);
+            JschUtil.close(session);
+        }
+    }*/
+
+    /**
+     * 将ssh信息同步到其他工作空间
+     *
+     * @param ids            多给节点ID
+     * @param nowWorkspaceId 当前的工作空间ID
+     * @param workspaceId    同步到哪个工作空间
+     */
+    public void syncToWorkspace(String ids, String nowWorkspaceId, String workspaceId) {
+        StrUtil.splitTrim(ids, StrUtil.COMMA)
+            .forEach(id -> {
+                FtpModel data = super.getByKey(id, false, entity -> entity.set("workspaceId", nowWorkspaceId));
+                Assert.notNull(data, I18nMessageUtil.get("i18n.no_corresponding_ssh_info.d864"));
+                //
+                FtpModel where = new FtpModel();
+                where.setWorkspaceId(workspaceId);
+                where.setMachineFtp(data.getMachineFtp());
+                FtpModel ftpModel = super.queryByBean(where);
+                Assert.isNull(ftpModel, I18nMessageUtil.get("i18n.workspace_ssh_already_exists.ccc0"));
+                // 不存在则添加 信息
+                data.setId(null);
+                data.setWorkspaceId(workspaceId);
+                data.setCreateTimeMillis(null);
+                data.setModifyTimeMillis(null);
+                data.setModifyUser(null);
+                data.setHost(null);
+                data.setUser(null);
+                data.setPassword(null);
+                data.setPort(null);
+                data.setCharset(null);
+                data.setTimeout(null);
+                super.insert(data);
+            });
+    }
+
+    public long countByMachine(String machineSshId) {
+        FtpModel ftpModel = new FtpModel();
+        ftpModel.setMachineFtpId(machineSshId);
+        return this.count(ftpModel);
+    }
+
+    public void existsSsh(String workspaceId, String machineFtpId) {
+        //
+        FtpModel where = new FtpModel();
+        where.setWorkspaceId(workspaceId);
+        where.setMachineFtpId(machineFtpId);
+        FtpModel data = this.queryByBean(where);
+        Assert.isNull(data, () -> I18nMessageUtil.get("i18n.ssh_already_exists_in_workspace.569e") + data.getName());
+    }
+
+    public boolean existsFtp2(String workspaceId, String machineFtpId) {
+        //
+        FtpModel where = new FtpModel();
+        where.setWorkspaceId(workspaceId);
+        where.setMachineFtpId(machineFtpId);
+        return this.exists(where);
+    }
+
+    public void insert(MachineFtpModel machineFtpModel, String workspaceId) {
+        FtpModel data = new FtpModel();
+        data.setWorkspaceId(workspaceId);
+        data.setName(machineFtpModel.getName());
+        data.setGroup(machineFtpModel.getGroupName());
+        data.setMachineFtpId(machineFtpModel.getId());
+        data.setMachineFtpId(machineFtpModel.getId());
+        this.insert(data);
+    }
+
+    /**
+     * 创建文件上传 进度
+     *
+     * @param logRecorder 日志记录器
+     * @return 进度监听
+     */
+    public MySftp.ProgressMonitor createProgressMonitor(LogRecorder logRecorder) {
+        Set<Integer> progressRangeList = ConcurrentHashMap.newKeySet((int) Math.floor((float) 100 / buildExtConfig.getLogReduceProgressRatio()));
+        return new MySftp.ProgressMonitor() {
+            @Override
+            public void rest() {
+                progressRangeList.clear();
+            }
+
+            @Override
+            public void progress(String desc, long max, long now) {
+                double progressPercentage = Math.floor(((float) now / max) * 100);
+                int progressRange = (int) Math.floor(progressPercentage / buildExtConfig.getLogReduceProgressRatio());
+                if (progressRangeList.add(progressRange)) {
+                    //  total, progressSize
+                    logRecorder.system(I18nMessageUtil.get("i18n.upload_progress_with_units.44ad"), desc,
+                        FileUtil.readableFileSize(now), FileUtil.readableFileSize(max),
+                        NumberUtil.formatPercent(((float) now / max), 0)
+                    );
+                }
+            }
+        };
+    }
+
+    public FtpModel getByMachineSshId(String id) {
+        FtpModel model = new FtpModel();
+        model.setMachineFtpId(id);
+        return queryByBean(model);
+    }
+}

+ 11 - 0
modules/server/src/main/resources/menus/zh-CN/index.json

@@ -74,6 +74,17 @@
       }
     ]
   },
+  {
+    "title": "FTP管理",
+    "icon_v3": "code",
+    "id": "ftpManager",
+    "childs": [
+      {
+        "title": "FTP列表",
+        "id": "ftpList"
+      }
+    ]
+  },
   {
     "title": "脚本管理",
     "icon_v3": "file-text",

+ 5 - 0
modules/server/src/main/resources/menus/zh-CN/system.json

@@ -19,6 +19,11 @@
         "title": "SSH管理",
         "role": "system"
       },
+      {
+        "id": "machine_ftp_info",
+        "title": "FTP管理",
+        "role": "system"
+      },
       {
         "id": "machine_docker_info",
         "title": "Docker管理",

+ 22 - 1
modules/server/src/main/resources/sql-view/table.all.v1.2.csv

@@ -40,7 +40,7 @@ SCRIPT_LIBRARY,script,text,,,false,false,描述,
 SCRIPT_LIBRARY,machineIds,text,,,false,false,关联的机器节点,
 SCRIPT_LIBRARY,version,String,50,,true,false,版本,
 
-MACHINE_FTP_INFO,id,String,50,,true,true,id,ssh信息表
+MACHINE_FTP_INFO,id,String,50,,true,true,id,ftp信息表
 MACHINE_FTP_INFO,createTimeMillis,Long,,,false,false,数据创建时间,
 MACHINE_FTP_INFO,modifyTimeMillis,Long,,,false,false,数据修改时间,
 MACHINE_FTP_INFO,modifyUser,String,50,,false,false,修改人,
@@ -53,6 +53,7 @@ MACHINE_FTP_INFO,password,String,100,,false,false,密码,
 MACHINE_FTP_INFO,charset,String,100,,false,false,编码格式,
 MACHINE_FTP_INFO,serverLanguageCode,String,50,,false,false,设置服务器语言,
 MACHINE_FTP_INFO,systemKey,String,10,,false,false,系统关键词,
+MACHINE_FTP_INFO,allowEditSuffix,TEXT,,,false,false,允许编辑的后缀文件,
 MACHINE_FTP_INFO,timeout,Integer,,0,false,false,超时时间,
 MACHINE_FTP_INFO,mode,String,10,,true,false,主动模式或者被动模式,
 MACHINE_FTP_INFO,status,TINYINT,,,true,false,状态{0,无法连接,1 正常},
@@ -67,3 +68,23 @@ FILE_RELEASE_TASK_TEMPLATE,name,String,255,,true,false,名称,
 FILE_RELEASE_TASK_TEMPLATE,templateTag,String,50,,true,false,模板标记,
 FILE_RELEASE_TASK_TEMPLATE,fileType,TINYINT,,,false,false,文件类型,
 FILE_RELEASE_TASK_TEMPLATE,data,TEXT,,,false,false,发布之前的脚本,
+
+FTP_INFO,id,String,50,,true,true,id,ftp信息表
+FTP_INFO,createTimeMillis,Long,,,false,false,数据创建时间,
+FTP_INFO,modifyTimeMillis,Long,,,false,false,数据修改时间,
+FTP_INFO,modifyUser,String,50,,false,false,修改人,
+FTP_INFO,workspaceId,String,50,,true,false,所属工作空间,
+FTP_INFO,name,String,50,,false,false,名称,
+FTP_INFO,host,String,100,,true,false,ssh host IP,
+FTP_INFO,port,Integer,,,true,false,端口,
+FTP_INFO,user,String,100,,true,false,用户,
+FTP_INFO,password,String,100,,false,false,密码,
+FTP_INFO,charset,String,100,,false,false,编码格式,
+FTP_INFO,serverLanguageCode,String,50,,false,false,设置服务器语言,
+FTP_INFO,systemKey,String,10,,false,false,系统关键词,
+FTP_INFO,fileDirs,TEXT,,,false,false,文件目录,
+FTP_INFO,allowEditSuffix,TEXT,,,false,false,允许编辑的后缀文件,
+FTP_INFO,group,String,10,,true,false,分组,
+FTP_INFO,machineFtpId,String,50,,true,false,机器ftpid,
+
+

+ 227 - 0
web-vue/src/api/ftp-file.ts

@@ -0,0 +1,227 @@
+///
+/// Copyright (c) 2019 Of Him Code Technology Studio
+/// Jpom is licensed under Mulan PSL v2.
+/// You can use this software according to the terms and conditions of the Mulan PSL v2.
+/// You may obtain a copy of Mulan PSL v2 at:
+/// 			http://license.coscl.org.cn/MulanPSL2
+/// THIS SOFTWARE IS PROVIDED ON AN "AS IS" BASIS, WITHOUT WARRANTIES OF ANY KIND, EITHER EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO NON-INFRINGEMENT, MERCHANTABILITY OR FIT FOR A PARTICULAR PURPOSE.
+/// See the Mulan PSL v2 for more details.
+///
+
+import axios from './config'
+import { loadRouterBase } from './config'
+
+/**
+ * 上传文件到 SSH 节点
+ * @param {
+ *  file: 文件 multipart/form-data,
+ *  id: ssh ID,
+ *  name: 当前目录,
+ *  path: 父级目录
+ * } formData
+ */
+export function uploadFile(baseUrl, formData) {
+  return axios({
+    url: baseUrl + 'upload',
+    headers: {
+      'Content-Type': 'multipart/form-data;charset=UTF-8'
+    },
+    method: 'post',
+    // 0 表示无超时时间
+    timeout: 0,
+    data: formData
+  })
+}
+
+export function uploadShardingFile(baseUrl, formData) {
+  return axios({
+    url: baseUrl + 'upload-sharding',
+    headers: {
+      'Content-Type': 'multipart/form-data;charset=UTF-8'
+    },
+    method: 'post',
+    // 0 表示无超时时间
+    timeout: 0,
+    data: formData
+  })
+}
+
+/**
+ * 授权目录列表
+ * @param {String} id
+ */
+export function getRootFileList(baseUrl, id) {
+  return axios({
+    url: baseUrl + 'root_file_data.json',
+    method: 'post',
+    data: { id }
+  })
+}
+
+/**
+ * 文件列表
+ * @param {id, path, children} params
+ */
+export function getFileList(baseUrl, params) {
+  return axios({
+    url: baseUrl + 'list_file_data.json',
+    method: 'post',
+    data: params
+  })
+}
+
+/**
+ * 下载文件
+ * 下载文件的返回是 blob 类型,把 blob 用浏览器下载下来
+ * @param {id, path, name} params
+ */
+export function downloadFile(baseUrl, params) {
+  return loadRouterBase(baseUrl + 'download', params)
+}
+
+/**
+ * 删除文件
+ * @param {id, path, name} params x
+ */
+export function deleteFile(baseUrl, params) {
+  return axios({
+    url: baseUrl + 'delete.json',
+    method: 'post',
+    data: params
+  })
+}
+
+/**
+ * 读取文件
+ * @param {id, path, name} params x
+ */
+export function readFile(baseUrl, params) {
+  return axios({
+    url: baseUrl + 'read_file_data.json',
+    method: 'post',
+    data: params
+  })
+}
+
+/**
+ * 保存文件
+ * @param {id, path, name,content} params x
+ */
+export function updateFileData(baseUrl, params) {
+  return axios({
+    url: baseUrl + 'update_file_data.json',
+    method: 'post',
+    data: params
+  })
+}
+
+/**
+ * 新增目录  或文件
+ * @param params
+ * @returns {id, path, name,unFolder} params x
+ */
+export function newFileFolder(baseUrl, params) {
+  return axios({
+    url: baseUrl + 'new_file_folder.json',
+    method: 'post',
+    data: params
+  })
+}
+
+/**
+ * 修改目录或文件名称
+ * @param params
+ * @returns {id, levelName, filename,newname} params x
+ */
+export function renameFileFolder(baseUrl, params) {
+  return axios({
+    url: baseUrl + 'rename.json',
+    method: 'post',
+    data: params
+  })
+}
+
+/**
+ * 修改文件权限
+ * @param {*} baseUrl
+ * @param {
+ *  String id,
+ *  String allowPathParent,
+ *  String nextPath,
+ *  String fileName,
+ *  String permissionValue
+ * } params
+ * @returns
+ */
+export function changeFilePermission(baseUrl, params) {
+  return axios({
+    url: baseUrl + 'change_file_permission.json',
+    method: 'post',
+    data: params
+  })
+}
+
+/**
+ * 权限字符串转权限对象
+ * @param {String} str "lrwxr-xr-x"
+ * @returns
+ */
+export function parsePermissions(str) {
+  const permissions = { owner: {}, group: {}, others: {} }
+
+  const chars = str.split('')
+  permissions.owner.read = chars[1] === 'r'
+  permissions.owner.write = chars[2] === 'w'
+  permissions.owner.execute = chars[3] === 'x'
+
+  permissions.group.read = chars[4] === 'r'
+  permissions.group.write = chars[5] === 'w'
+  permissions.group.execute = chars[6] === 'x'
+
+  permissions.others.read = chars[7] === 'r'
+  permissions.others.write = chars[8] === 'w'
+  permissions.others.execute = chars[9] === 'x'
+
+  return permissions
+}
+
+/**
+ * 文件权限字符串转权限值
+ * @param {
+ *  owner: { read: false, write: false, execute: false, },
+ *  group: { read: false, write: false, execute: false, },
+ *  others: { read: false, write: false, execute: false, },
+ * } permissions
+ * @returns
+ */
+export function calcFilePermissionValue(permissions) {
+  let value = 0
+  if (permissions.owner.read) {
+    value += 400
+  }
+  if (permissions.owner.write) {
+    value += 200
+  }
+  if (permissions.owner.execute) {
+    value += 100
+  }
+  if (permissions.group.read) {
+    value += 40
+  }
+  if (permissions.group.write) {
+    value += 20
+  }
+  if (permissions.group.execute) {
+    value += 10
+  }
+  if (permissions.others.read) {
+    value += 4
+  }
+  if (permissions.others.write) {
+    value += 2
+  }
+  if (permissions.others.execute) {
+    value += 1
+  }
+  return value
+}

+ 68 - 0
web-vue/src/api/ftp.ts

@@ -0,0 +1,68 @@
+///
+/// Copyright (c) 2019 Of Him Code Technology Studio
+/// Jpom is licensed under Mulan PSL v2.
+/// You can use this software according to the terms and conditions of the Mulan PSL v2.
+/// You may obtain a copy of Mulan PSL v2 at:
+/// 			http://license.coscl.org.cn/MulanPSL2
+/// THIS SOFTWARE IS PROVIDED ON AN "AS IS" BASIS, WITHOUT WARRANTIES OF ANY KIND, EITHER EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO NON-INFRINGEMENT, MERCHANTABILITY OR FIT FOR A PARTICULAR PURPOSE.
+/// See the Mulan PSL v2 for more details.
+///
+
+import axios from './config'
+
+// ftp 列表
+export function getFtpList(params) {
+  return axios({
+    url: '/node/ftp/list_data.json',
+    method: 'post',
+    data: params
+  })
+}
+
+// ftp group all
+export function getFtpGroupAll() {
+  return axios({
+    url: '/node/ftp/list-group-all',
+    method: 'get'
+  })
+}
+
+/**
+ * 编辑 FTP
+ * @param {*} params
+ * params.type = {'add': 表示新增, 'edit': 表示修改}
+ */
+export function editFtp(params) {
+  return axios({
+    url: '/node/ftp/save.json',
+    method: 'post',
+
+    params
+  })
+}
+
+// 删除 FTP
+export function deleteFtp(id) {
+  return axios({
+    url: '/node/ftp/del.json',
+    method: 'post',
+    data: {id}
+  })
+}
+
+// 删除 FTP
+export function deleteForeFtp(id) {
+  return axios({
+    url: '/node/ftp/del-fore',
+    method: 'post',
+    data: {id}
+  })
+}
+
+export function syncToWorkspace(params) {
+  return axios({
+    url: '/node/ftp/sync-to-workspace',
+    method: 'get',
+    params: params
+  })
+}

+ 123 - 0
web-vue/src/api/system/assets-ftp.ts

@@ -0,0 +1,123 @@
+///
+/// Copyright (c) 2019 Of Him Code Technology Studio
+/// Jpom is licensed under Mulan PSL v2.
+/// You can use this software according to the terms and conditions of the Mulan PSL v2.
+/// You may obtain a copy of Mulan PSL v2 at:
+/// 			http://license.coscl.org.cn/MulanPSL2
+/// THIS SOFTWARE IS PROVIDED ON AN "AS IS" BASIS, WITHOUT WARRANTIES OF ANY KIND, EITHER EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO NON-INFRINGEMENT, MERCHANTABILITY OR FIT FOR A PARTICULAR PURPOSE.
+/// See the Mulan PSL v2 for more details.
+///
+
+import {t} from '@/i18n'
+import axios, {loadRouterBase} from '@/api/config'
+
+// ftp 列表
+export function machineFtpListData(params) {
+  return axios({
+    url: '/system/assets/ftp/list-data',
+    method: 'post',
+    data: params
+  })
+}
+
+
+export function machineFtpListGroup(params) {
+  return axios({
+    url: '/system/assets/ftp/list-group',
+    method: 'get',
+    params: params
+  })
+}
+
+// 编辑ftp
+export function machineFtpEdit(params) {
+  return axios({
+    url: '/system/assets/ftp/edit',
+    method: 'post',
+    data: params
+  })
+}
+
+// 删除 ftp
+export function machineFtpDelete(params) {
+  return axios({
+    url: '/system/assets/ftp/delete',
+    method: 'post',
+    data: params
+  })
+}
+
+// 分配 ftp
+export function machineFtpDistribute(params) {
+  return axios({
+    url: '/system/assets/ftp/distribute',
+    method: 'post',
+    data: params
+  })
+}
+
+// ftp 关联工作空间的数据
+export function machineListGroupWorkspaceFtp(params) {
+  return axios({
+    url: '/system/assets/ftp/list-workspace-ftp',
+    method: 'get',
+    params: params
+  })
+}
+
+export function machineFtpSaveWorkspaceConfig(params) {
+  return axios({
+    url: '/system/assets/ftp/save-workspace-config',
+    method: 'post',
+    data: params
+  })
+}
+
+/**
+ * restHideField by id
+ * @param {String} id
+ * @returns
+ */
+export function restHideField(id) {
+  return axios({
+    url: '/system/assets/ftp/rest-hide-field',
+    method: 'post',
+    data: {id}
+  })
+}
+
+/*
+ * 下载导入模板
+ *
+ */
+export function importTemplate(data) {
+  return loadRouterBase('/system/assets/ftp/import-template', data)
+}
+
+/*
+ * 导出数据
+ *
+ */
+export function exportData(data) {
+  return loadRouterBase('/system/assets/ftp/export-data', data)
+}
+
+// 导入数据
+export function importData(formData) {
+  return axios({
+    url: '/system/assets/ftp/import-data',
+    headers: {
+      'Content-Type': 'multipart/form-data;charset=UTF-8'
+    },
+    method: 'post',
+    // 0 表示无超时时间
+    timeout: 0,
+    data: formData
+  })
+}
+
+export const statusMap = {
+  0: {desc: t('i18n_757a730c9e'), color: 'red'},
+  1: {desc: t('i18n_fd6e80f1e0'), color: 'green'},
+  2: {desc: t('i18n_46158d0d6e'), color: 'orange'}
+}

+ 1281 - 0
web-vue/src/pages/ftp/ftp-file.vue

@@ -0,0 +1,1281 @@
+<template>
+  <!-- 布局 -->
+  <a-layout class="ssh-file-layout">
+    <!-- 目录树 -->
+    <a-layout-sider theme="light" class="sider" width="25%">
+      <a-row class="dir-container">
+        <a-space>
+          <a-button size="small" type="primary" @click="loadData()">{{ $t('i18n_694fc5efa9') }}</a-button>
+          <a-dropdown>
+            <template #overlay>
+              <a-menu>
+                <a-menu-item
+                  v-for="item in sortMethodList"
+                  :key="item.key"
+                  @click="
+                    () => {
+                      changeSort(item.key, sortMethod.asc)
+                    }
+                  "
+                  >{{ item.name }}</a-menu-item
+                >
+              </a-menu>
+            </template>
+
+            <a-button
+              size="small"
+              type="primary"
+              @click="
+                () => {
+                  changeSort(sortMethod.key, !sortMethod.asc)
+                }
+              "
+            >
+              {{
+                sortMethodList.find((item) => {
+                  return item.key === sortMethod.key
+                }) &&
+                sortMethodList.find((item) => {
+                  return item.key === sortMethod.key
+                }).name
+              }}{{ $t('i18n_c360e994db') }}
+              <SortAscendingOutlined v-if="sortMethod.asc" />
+              <SortDescendingOutlined v-else />
+            </a-button>
+          </a-dropdown>
+        </a-space>
+      </a-row>
+      <a-empty v-if="treeList.length === 0" :image="Empty.PRESENTED_IMAGE_SIMPLE" />
+      <a-spin v-else :tip="$t('i18n_f013ea9dcb')" :spinning="loading">
+        <div class="tree-container">
+          <a-directory-tree
+            v-model:selectedKeys="selectedKeys"
+            v-model:expandedKeys="expandedKeys"
+            :tree-data="treeList"
+            :field-names="replaceFields"
+            @select="onSelect"
+            @expand="
+              (expandedKeys, { expanded, node }) => {
+                if (expanded) {
+                  onSelect(expandedKeys, { node })
+                }
+              }
+            "
+          >
+          </a-directory-tree>
+        </div>
+      </a-spin>
+    </a-layout-sider>
+    <!-- 表格 -->
+    <a-layout-content class="file-content">
+      <!-- <div ref="filter" class="filter"></div> -->
+      <a-table
+        size="middle"
+        :data-source="sortFileList"
+        :loading="loading"
+        :columns="columns"
+        :pagination="false"
+        bordered
+        :scroll="{
+          x: 'max-content'
+        }"
+      >
+        <template #title>
+          <a-space>
+            <a-dropdown :disabled="!tempNode.nextPath">
+              <a-button size="small" type="primary" @click="(e) => e.preventDefault()">{{
+                $t('i18n_01198a1673')
+              }}</a-button>
+              <template #overlay>
+                <a-menu>
+                  <a-menu-item @click="handleUpload">
+                    <a-space><FileAddOutlined />{{ $t('i18n_a6fc9e3ae6') }}</a-space>
+                  </a-menu-item>
+                  <a-menu-item @click="handleUploadZip">
+                    <a-space><FileZipOutlined />{{ $t('i18n_66b71b06c6') }}</a-space>
+                  </a-menu-item>
+                </a-menu>
+              </template>
+            </a-dropdown>
+<!--            <a-button
+              size="small"
+              :disabled="!tempNode.nextPath"
+              type="primary"
+              @click="uploadShardingFileVisible = true"
+              >{{ $t('i18n_dda8b4c10f') }}</a-button
+            >-->
+            <a-dropdown :disabled="!tempNode.nextPath">
+              <a-button size="small" type="primary" @click="(e) => e.preventDefault()">{{
+                $t('i18n_26bb841878')
+              }}</a-button>
+              <template #overlay>
+                <a-menu>
+                  <a-menu-item @click="handleAddFolder">
+                    <a-space>
+                      <FolderAddOutlined />
+                      <a-space>{{ $t('i18n_547ee197e5') }}</a-space>
+                    </a-space>
+                  </a-menu-item>
+                  <a-menu-item @click="handleAddFile">
+                    <a-space>
+                      <FileAddOutlined />
+                      <a-space>{{ $t('i18n_497ddf508a') }}</a-space>
+                    </a-space>
+                  </a-menu-item>
+                </a-menu>
+              </template>
+            </a-dropdown>
+            <a-button size="small" :disabled="!tempNode.nextPath" type="primary" @click="loadFileList()">{{
+              $t('i18n_694fc5efa9')
+            }}</a-button>
+            <a-button size="small" :disabled="!tempNode.nextPath" type="primary" danger @click="handleDeletePath()">{{
+              $t('i18n_2f4aaddde3')
+            }}</a-button>
+            <div>
+              {{ $t('i18n_4cbc136874') }}
+              <a-switch
+                v-model:checked="listShowDir"
+                :disabled="!tempNode.nextPath"
+                :checked-children="$t('i18n_4d775d4cd7')"
+                :un-checked-children="$t('i18n_dce5379cb9')"
+                @change="changeListShowDir"
+              />
+            </div>
+            <span v-if="nowPath">{{ $t('i18n_4e33dde280') }}{{ nowPath }}</span>
+            <!-- <span v-if="this.nowPath">{{ this.tempNode.parentDir }}</span> -->
+          </a-space>
+        </template>
+
+        <template #bodyCell="{ column, text, record }">
+          <template v-if="column.dataIndex === 'name'">
+            <a-tooltip placement="topLeft">
+              <template #title>
+                <div>{{ $t('i18n_551e46c0ea') }}{{ text }}</div>
+                <div>{{ $t('i18n_964d939a96') }}{{ record.longname }}</div>
+              </template>
+              <a-dropdown :trigger="['contextmenu']">
+                <div>{{ text }}</div>
+                <template #overlay>
+                  <a-menu>
+                    <a-menu-item key="2">
+                      <a-button type="link" @click="handleRenameFile(record)"
+                        ><HighlightOutlined /> {{ $t('i18n_c8ce4b36cb') }}
+                      </a-button>
+                    </a-menu-item>
+                  </a-menu>
+                </template>
+              </a-dropdown>
+
+              <!-- <span>{{ text }}</span> -->
+            </a-tooltip>
+          </template>
+          <template v-else-if="column.dataIndex === 'dir'">
+            <a-tooltip
+              placement="topLeft"
+              :title="`${record.link ? $t('i18n_bfe68d5844') : text ? $t('i18n_767fa455bb') : $t('i18n_2a0c4740f1')}`"
+            >
+              <span>{{
+                record.link ? $t('i18n_bfe68d5844') : text ? $t('i18n_767fa455bb') : $t('i18n_2a0c4740f1')
+              }}</span>
+            </a-tooltip>
+          </template>
+          <template v-else-if="column.dataIndex === 'size'">
+            <a-tooltip placement="topLeft" :title="renderSize(text)">
+              <span>{{ renderSize(text) }}</span>
+            </a-tooltip>
+          </template>
+          <template v-else-if="column.tooltip">
+            <a-tooltip placement="topLeft" :title="text">
+              <span>{{ text }}</span>
+            </a-tooltip>
+          </template>
+          <template v-else-if="column.dataIndex === 'operation'">
+            <a-space>
+              <a-tooltip :title="$t('i18n_af0df2e295')">
+                <a-button size="small" type="primary" :disabled="!record.textFileEdit" @click="handleEdit(record)">{{
+                  $t('i18n_95b351c862')
+                }}</a-button>
+              </a-tooltip>
+<!--              <a-tooltip :title="$t('i18n_5cc7e8e30a')">
+                <a-button size="small" type="primary" @click="handleFilePermission(record)">{{
+                  $t('i18n_ba6e91fa9e')
+                }}</a-button>
+              </a-tooltip>-->
+              <a-button size="small" type="primary" :disabled="record.dir" @click="handleDownload(record)">{{
+                $t('i18n_f26ef91424')
+              }}</a-button>
+              <a-button size="small" type="primary" danger @click="handleDelete(record)">{{
+                $t('i18n_2f4aaddde3')
+              }}</a-button>
+            </a-space>
+          </template>
+        </template>
+      </a-table>
+      <!-- 上传文件 -->
+      <CustomModal
+        v-if="uploadFileVisible"
+        v-model:open="uploadFileVisible"
+        destroy-on-close
+        width="300px"
+        :title="$t('i18n_a6fc9e3ae6')"
+        :confirm-loading="confirmLoading"
+        :footer="null"
+        :mask-closable="true"
+        @cancel="closeUploadFile"
+      >
+        <a-upload
+          :file-list="uploadFileList"
+          :before-upload="beforeUpload"
+          :accept="`${uploadFileZip ? ZIP_ACCEPT : ''}`"
+          :multiple="!uploadFileZip"
+          @remove="handleRemove"
+        >
+          <a-button>
+            <UploadOutlined />
+            {{ $t('i18n_fd7e0c997d') }}
+            {{ uploadFileZip ? $t('i18n_c806d0fa38') : '' }}
+          </a-button>
+        </a-upload>
+        <br />
+        <a-button
+          type="primary"
+          :disabled="uploadFileList.length === 0"
+          :loading="confirmLoading"
+          @click="startUpload"
+          >{{ $t('i18n_020f1ecd62') }}</a-button
+        >
+      </CustomModal>
+      <!-- 分片上传 -->
+      <CustomModal
+        v-if="uploadShardingFileVisible"
+        v-model:open="uploadShardingFileVisible"
+        destroy-on-close
+        :confirm-loading="confirmLoading"
+        :closable="!confirmLoading"
+        :keyboard="false"
+        width="35vw"
+        :title="$t('i18n_d65551b090')"
+        :footer="null"
+        :mask-closable="false"
+      >
+        <a-space direction="vertical" size="large" style="width: 100%">
+          <a-alert :message="$t('i18n_776bf504a4')" type="warning">
+            <template #description>
+              <ul>
+                <li>
+                  {{ $t('i18n_383952103d') }}
+                </li>
+                <li>{{ $t('i18n_d85279c536') }}</li>
+              </ul>
+            </template>
+          </a-alert>
+          <a-upload
+            :file-list="uploadFileList"
+            :before-upload="
+              (file) => {
+                uploadFileList = [file]
+                return false
+              }
+            "
+            multiple
+            :disabled="!!percentage"
+            @remove="
+              (file) => {
+                const index = uploadFileList.indexOf(file)
+                //const newFileList = this.uploadFileList.slice();
+
+                uploadFileList.splice(index, 1)
+                return true
+              }
+            "
+          >
+            <template v-if="percentage">
+              <template v-if="uploadFileList?.length">
+                <LoadingOutlined v-if="uploadFileList.length > 1" />
+              </template>
+            </template>
+
+            <a-button v-else><UploadOutlined />{{ $t('i18n_fd7e0c997d') }}</a-button>
+          </a-upload>
+
+          <a-row v-if="percentage">
+            <a-col span="24">
+              <a-progress :percent="percentage" class="max-progress">
+                <template #format="percent">
+                  {{ percent }}%<template v-if="percentageInfo.total">
+                    ({{ renderSize(percentageInfo.total) }})
+                  </template>
+                  <template v-if="percentageInfo.duration">
+                    {{ $t('i18n_833249fb92') }}:{{ formatDuration(percentageInfo.duration) }}
+                  </template>
+                </template>
+              </a-progress>
+            </a-col>
+          </a-row>
+
+          <a-button type="primary" :disabled="fileUploadDisabled" @click="startUploadSharding">{{
+            $t('i18n_020f1ecd62')
+          }}</a-button>
+        </a-space>
+      </CustomModal>
+      <!--  新增文件 目录    -->
+      <CustomModal
+        v-if="addFileFolderVisible"
+        v-model:open="addFileFolderVisible"
+        width="300px"
+        :title="temp.addFileOrFolderType === 1 ? $t('i18n_2d9e932510') : $t('i18n_e48a715738')"
+        :footer="null"
+        :mask-closable="true"
+      >
+        <a-space direction="vertical" style="width: 100%">
+          <span v-if="nowPath">{{ $t('i18n_4e33dde280') }}{{ nowPath }}</span>
+          <!-- <a-tag v-if="">目录创建成功后需要手动刷新右边树才能显示出来哟</a-tag> -->
+          <a-tooltip :title="temp.addFileOrFolderType === 1 ? $t('i18n_fe1b192913') : ''">
+            <a-input v-model:value="temp.fileFolderName" :placeholder="$t('i18n_55939c108f')" />
+          </a-tooltip>
+          <a-row type="flex" justify="center">
+            <a-button
+              type="primary"
+              :disabled="!temp.fileFolderName || temp.fileFolderName.length === 0"
+              @click="startAddFileFolder"
+              >{{ $t('i18n_e83a256e4f') }}</a-button
+            >
+          </a-row>
+        </a-space>
+      </CustomModal>
+      <!-- 编辑文件 -->
+      <CustomModal
+        v-if="editFileVisible"
+        v-model:open="editFileVisible"
+        destroy-on-close
+        :confirm-loading="confirmLoading"
+        width="80vw"
+        :title="$t('i18n_47ff744ef6')"
+        :cancel-text="$t('i18n_b15d91274e')"
+        :mask-closable="true"
+        @ok="updateFileData"
+      >
+        <code-editor v-model:content="temp.fileContent" height="60vh" show-tool :file-suffix="temp.name">
+          <template #tool_before>
+            <a-tag>
+              {{
+                ((temp.allowPathParent || '/ ') + '/' + (temp.nextPath || '/') + '/' + (temp.name || '/')).replace(
+                  new RegExp('//+', 'gm'),
+                  '/'
+                )
+              }}
+              <!-- {{ temp.name }} -->
+            </a-tag>
+          </template>
+        </code-editor>
+      </CustomModal>
+      <!-- 从命名文件/文件夹 -->
+      <CustomModal
+        v-if="renameFileFolderVisible"
+        v-model:open="renameFileFolderVisible"
+        destroy-on-close
+        width="300px"
+        :title="`${$t('i18n_c8ce4b36cb')}`"
+        :footer="null"
+        :mask-closable="true"
+      >
+        <a-space direction="vertical" style="width: 100%">
+          <a-input v-model:value="temp.fileFolderName" :placeholder="$t('i18n_f139c5cf32')" />
+
+          <a-row v-if="temp.fileFolderName" type="flex" justify="center">
+            <a-button
+              :loading="confirmLoading"
+              type="primary"
+              :disabled="temp.fileFolderName.length === 0 || temp.fileFolderName === temp.oldFileFolderName"
+              @click="renameFileFolder"
+              >{{ $t('i18n_e83a256e4f') }}</a-button
+            >
+          </a-row>
+        </a-space>
+      </CustomModal>
+
+      <!-- 修改文件权限 -->
+      <CustomModal
+        v-if="editFilePermissionVisible"
+        v-model:open="editFilePermissionVisible"
+        destroy-on-close
+        width="400px"
+        :title="`${$t('i18n_5cc7e8e30a')}`"
+        :footer="null"
+        :mask-closable="true"
+      >
+        <a-row>
+          <a-col :span="6"
+            ><span class="title">{{ $t('i18n_ba6e91fa9e') }}</span></a-col
+          >
+          <a-col :span="6"
+            ><span class="title">{{ $t('i18n_8306971039') }}</span></a-col
+          >
+          <a-col :span="6"
+            ><span class="title">{{ $t('i18n_e72a0ba45a') }}</span></a-col
+          >
+          <a-col :span="6"
+            ><span class="title">{{ $t('i18n_0d98c74797') }}</span></a-col
+          >
+        </a-row>
+        <a-row>
+          <a-col :span="6">
+            <span>{{ $t('i18n_75769d1ac8') }}</span>
+          </a-col>
+          <a-col :span="6">
+            <a-checkbox v-model:checked="permissions.owner.read" />
+          </a-col>
+          <a-col :span="6">
+            <a-checkbox v-model:checked="permissions.group.read" />
+          </a-col>
+          <a-col :span="6">
+            <a-checkbox v-model:checked="permissions.others.read" />
+          </a-col>
+        </a-row>
+        <a-row>
+          <a-col :span="6">
+            <span>{{ $t('i18n_4d7dc6c5f8') }}</span>
+          </a-col>
+          <a-col :span="6">
+            <a-checkbox v-model:checked="permissions.owner.write" />
+          </a-col>
+          <a-col :span="6">
+            <a-checkbox v-model:checked="permissions.group.write" />
+          </a-col>
+          <a-col :span="6">
+            <a-checkbox v-model:checked="permissions.others.write" />
+          </a-col>
+        </a-row>
+        <a-row>
+          <a-col :span="6">
+            <span>{{ $t('i18n_1a6aa24e76') }}</span>
+          </a-col>
+          <a-col :span="6">
+            <a-checkbox v-model:checked="permissions.owner.execute" />
+          </a-col>
+          <a-col :span="6">
+            <a-checkbox v-model:checked="permissions.group.execute" />
+          </a-col>
+          <a-col :span="6">
+            <a-checkbox v-model:checked="permissions.others.execute" />
+          </a-col>
+        </a-row>
+        <a-row type="flex" style="margin-top: 20px">
+          <a-button type="primary" @click="updateFilePermissions">{{ $t('i18n_49e56c7b90') }}</a-button>
+        </a-row>
+        <!-- <a-row>
+            <a-alert style="margin-top: 20px" :message="permissionTips" type="success" />
+          </a-row> -->
+      </CustomModal>
+    </a-layout-content>
+  </a-layout>
+</template>
+<script>
+import {
+  deleteFile,
+  downloadFile,
+  getFileList,
+  getRootFileList,
+  newFileFolder,
+  readFile,
+  renameFileFolder,
+  updateFileData,
+  uploadFile,
+  parsePermissions,
+  calcFilePermissionValue,
+  changeFilePermission,
+  uploadShardingFile
+} from '@/api/ftp-file'
+
+import codeEditor from '@/components/codeEditor'
+import { ZIP_ACCEPT, renderSize, parseTime, concurrentExecution, formatDuration } from '@/utils/const'
+import { Empty } from 'ant-design-vue'
+import { uploadPieces } from '@/utils/upload-pieces'
+export default {
+  components: {
+    codeEditor
+  },
+  inject: ['globalLoading'],
+  props: {
+    ftpId: {
+      type: String,
+      default: ''
+    },
+    machineFtpId: {
+      type: String,
+      default: ''
+    }
+  },
+  data() {
+    return {
+      Empty,
+      loading: false,
+      treeList: [],
+      fileList: [],
+      uploadFileList: [],
+      tempNode: {},
+      temp: {},
+      uploadFileVisible: false,
+      uploadFileZip: false,
+      ZIP_ACCEPT: ZIP_ACCEPT,
+      renameFileFolderVisible: false,
+      listShowDir: false,
+      tableHeight: '80vh',
+      replaceFields: {
+        children: 'children',
+        title: 'name',
+        key: 'key'
+      },
+      columns: [
+        {
+          title: this.$t('i18n_d2e2560089'),
+          dataIndex: 'name',
+          width: 200,
+          ellipsis: true,
+
+          sorter: (a, b) => (a.name || '').localeCompare(b.name || '')
+        },
+        {
+          title: this.$t('i18n_28b988ce6a'),
+          dataIndex: 'dir',
+          width: '100px',
+          ellipsis: true
+        },
+        {
+          title: this.$t('i18n_396b7d3f91'),
+          dataIndex: 'size',
+          width: 120,
+          ellipsis: true,
+
+          sorter: (a, b) => Number(a.size) - new Number(b.size)
+        },
+        {
+          title: this.$t('i18n_ba6e91fa9e'),
+          dataIndex: 'permissions',
+          width: 120,
+          ellipsis: true,
+          tooltip: true
+        },
+        {
+          title: this.$t('i18n_1303e638b5'),
+          dataIndex: 'modifyTime',
+          width: '170px',
+          ellipsis: true,
+          customRender: ({ text }) => parseTime(text),
+          sorter: (a, b) => Number(a.modifyTime) - new Number(b.modifyTime)
+        },
+        {
+          title: this.$t('i18n_2b6bc0f293'),
+          dataIndex: 'operation',
+          align: 'center',
+          fixed: 'right',
+
+          width: '220px'
+        }
+      ],
+
+      editFileVisible: false,
+      addFileFolderVisible: false,
+      editFilePermissionVisible: false,
+      permissions: {
+        owner: { read: false, write: false, execute: false },
+        group: { read: false, write: false, execute: false },
+        others: { read: false, write: false, execute: false }
+      },
+      // permissionTips: "",
+      sortMethodList: [
+        {
+          name: this.$t('i18n_29139c2a1a'),
+          key: 'name'
+        },
+        {
+          name: this.$t('i18n_1303e638b5'),
+          key: 'modifyTime'
+        }
+      ],
+
+      sortMethod: {
+        key: 'name',
+        asc: true
+      },
+      confirmLoading: false,
+      selectedKeys: [],
+      expandedKeys: [],
+      uploadShardingFileVisible: false,
+      percentage: 0,
+      percentageInfo: {}
+    }
+  },
+  computed: {
+    fileUploadDisabled() {
+      return this.uploadFileList.length === 0 || this.confirmLoading
+    },
+    nowPath() {
+      if (!this.tempNode.allowPathParent) {
+        return ''
+      }
+      return ((this.tempNode.allowPathParent || '') + '/' + (this.tempNode.nextPath || '')).replace(
+        new RegExp('//+', 'gm'),
+        '/'
+      )
+    },
+    baseUrl() {
+      if (this.ftpId) {
+        return '/node/ftp/'
+      }
+      return '/system/assets/ftp-file/'
+    },
+    reqDataId() {
+      return this.ftpId || this.machineFtpId
+    },
+    sortFileList() {
+      return this.fileList.slice(0).sort((a, b) => {
+        const aV = a[this.sortMethod.key] || ''
+        const bV = b[this.sortMethod.key] || ''
+        return this.sortMethod.asc ? bV.localeCompare(aV) : aV.localeCompare(bV)
+      })
+    }
+  },
+  mounted() {
+    this.listShowDir = Boolean(localStorage.getItem('ssh-list-show-dir'))
+    try {
+      this.sortMethod = JSON.parse(localStorage.getItem('ssh-list-sort') || JSON.stringify(this.sortMethod))
+    } catch (e) {
+      console.error(e)
+    }
+    this.loadData()
+  },
+  methods: {
+    formatDuration,
+    changeSort(key, asc) {
+      this.sortMethod = { key: key, asc: asc }
+      localStorage.setItem('ssh-list-sort', JSON.stringify(this.sortMethod))
+      this.loadData()
+    },
+    renderSize,
+    // 加载数据
+    loadData() {
+      this.loading = true
+      this.treeList = []
+      this.fileList = []
+      this.selectedKeys = []
+      this.expandedKeys = []
+      this.tempNode = {}
+      getRootFileList(this.baseUrl, this.reqDataId).then((res) => {
+        if (res.code === 200) {
+          this.treeList = res.data
+            .map((element, index) => {
+              return {
+                key: element.id,
+                name: element.allowPathParent,
+                allowPathParent: element.allowPathParent,
+                nextPath: '/',
+                isLeaf: false,
+                // 配置的授权目录可能不存在
+                disabled: !!element.error,
+                modifyTime: element.modifyTime,
+                activeKey: [index]
+              }
+            })
+            .sort((a, b) => {
+              const aV = a[this.sortMethod.key] || ''
+              const bV = b[this.sortMethod.key] || ''
+              return this.sortMethod.asc ? bV.localeCompare(aV) : aV.localeCompare(bV)
+            })
+        }
+        this.loading = false
+      })
+    },
+    /**
+     * 根据key获取树节点
+     * @param keys
+     * @returns {*}
+     */
+    getTreeNode(keys) {
+      let node = this.treeList.find((node) => node.activeKey[0] == keys.slice(0, 1)[0])
+      const nodeKeys = keys.slice(1)
+      for (let [index, key] of nodeKeys.entries()) {
+        if (key >= 0 && key < node.children.length) {
+          node = node.children.find((node) => node.activeKey.slice(index + 1, index + 2) == key)
+        } else {
+          throw new Error('Invalid key: ' + key)
+        }
+      }
+      return node
+    },
+    /**
+     * 更新树节点的方法抽离封装
+     * @param keys
+     * @param value
+     */
+    updateTreeChildren(keys, value) {
+      const node = this.getTreeNode(keys)
+      node.children = value
+    },
+    /**
+     * 文件列表转树结构
+     * @param data
+     */
+    fileList2TreeData(data) {
+      const node = this.tempNode
+      const children = data
+        .filter((element) => element.dir)
+        .map((element) => ({
+          key: element.id,
+          name: element.name,
+          allowPathParent: node.allowPathParent,
+          nextPath: (element.nextPath + '/' + element.name).replace(new RegExp('//+', 'gm'), '/'),
+          isLeaf: !element.dir,
+          // 可能有错误
+          disabled: !!element.error,
+          modifyTime: element.modifyTime
+        }))
+        .sort((a, b) => {
+          const aV = a[this.sortMethod.key] || ''
+          const bV = b[this.sortMethod.key] || ''
+          return this.sortMethod.asc ? bV.localeCompare(aV) : aV.localeCompare(bV)
+        })
+        .map((element, index) => ({ ...element, activeKey: node.activeKey.concat(index) }))
+      this.updateTreeChildren(node.activeKey, children)
+    },
+    /**
+     * 加载文件列表
+     */
+    loadTreeNode() {
+      const { allowPathParent, nextPath } = this.tempNode
+      // 请求参数
+      const params = {
+        id: this.reqDataId,
+        allowPathParent: allowPathParent,
+        nextPath: nextPath
+      }
+      this.fileList = []
+      this.loading = true
+      // 加载文件
+      getFileList(this.baseUrl, params).then((res) => {
+        if (res.code === 200) {
+          // let children = []
+          // 区分目录和文件
+          res.data.forEach((element) => {
+            if (element.dir) {
+              if (this.listShowDir) {
+                this.fileList.push({
+                  // path: node.dataRef.path,
+                  ...element
+                })
+              }
+            } else {
+              // 设置文件表格
+              this.fileList.push({
+                // path: node.dataRef.path,
+                ...element
+              })
+            }
+          })
+          //  更新tree 方法抽离封装
+          this.fileList2TreeData(res.data)
+        }
+        this.loading = false
+      })
+    },
+    // 选中目录
+    onSelect(selectedKeys, { node }) {
+      if (node.dataRef.disabled) {
+        return
+      }
+      // console.log(node.dataRef, this.tempNode.key);
+      if (node.dataRef.key === this.tempNode.key) {
+        return
+      }
+      this.tempNode = node.dataRef
+      this.loadTreeNode()
+    },
+    changeListShowDir() {
+      this.loadFileList()
+      localStorage.setItem('ssh-list-show-dir', this.listShowDir)
+    },
+    // 加载文件列表
+    loadFileList() {
+      if (Object.keys(this.tempNode).length === 0) {
+        $notification.warn({
+          message: this.$t('i18n_bcaf69a038')
+        })
+        return false
+      }
+      // 请求参数
+      const params = {
+        id: this.reqDataId,
+        allowPathParent: this.tempNode.allowPathParent,
+        nextPath: this.tempNode.nextPath
+      }
+      // this.fileList = [];
+      this.loading = true
+      // 加载文件
+      getFileList(this.baseUrl, params).then((res) => {
+        if (res.code === 200) {
+          // 区分目录和文件
+          this.fileList = res.data
+            .filter((element) => {
+              if (this.listShowDir) {
+                return true
+              }
+              return !element.dir
+            })
+            .map((element) => {
+              // 设置文件表格
+              return {
+                // path: this.tempNode.path,
+                ...element
+              }
+            })
+          // 更新tree
+          this.fileList2TreeData(res.data)
+        }
+        this.loading = false
+      })
+    },
+    // 上传文件
+    handleUpload() {
+      if (Object.keys(this.tempNode).length === 0) {
+        $notification.error({
+          message: this.$t('i18n_bcaf69a038')
+        })
+        return
+      }
+      this.uploadFileVisible = true
+      this.uploadFileZip = false
+    },
+    handleUploadZip() {
+      this.handleUpload()
+      this.uploadFileZip = true
+    },
+    handleAddFolder() {
+      this.addFileFolderVisible = true
+      // 目录1 文件2 标识
+      // addFileOrFolderType: 1,
+      //       fileFolderName: "",
+      this.temp = {
+        fileFolderName: '',
+        addFileOrFolderType: 1,
+        allowPathParent: this.tempNode.allowPathParent,
+        nextPath: this.tempNode.nextPath
+      }
+    },
+    handleAddFile() {
+      this.addFileFolderVisible = true
+      // 目录1 文件2 标识
+      // addFileOrFolderType: 1,
+      //       fileFolderName: "",
+      this.temp = {
+        fileFolderName: '',
+        addFileOrFolderType: 2,
+        allowPathParent: this.tempNode.allowPathParent,
+        nextPath: this.tempNode.nextPath
+      }
+    },
+    // closeAddFileFolder() {
+    //   this.addFileFolderVisible = false;
+    //   this.fileFolderName = "";
+    // },
+    // 确认新增文件  目录
+    startAddFileFolder() {
+      const params = {
+        id: this.reqDataId,
+        allowPathParent: this.temp.allowPathParent,
+        nextPath: this.temp.nextPath,
+        name: this.temp.fileFolderName,
+        unFolder: this.temp.addFileOrFolderType !== 1
+      }
+      newFileFolder(this.baseUrl, params).then((res) => {
+        if (res.code === 200) {
+          $notification.success({
+            message: res.msg
+          })
+          this.addFileFolderVisible = false
+          this.loadFileList()
+          // this.closeAddFileFolder();
+        }
+      })
+    },
+    handleRemove(file) {
+      const index = this.uploadFileList.indexOf(file)
+      const newFileList = this.uploadFileList.slice()
+      newFileList.splice(index, 1)
+      this.uploadFileList = newFileList
+      return true
+    },
+    beforeUpload(file) {
+      this.uploadFileList = [...this.uploadFileList, file]
+      return false
+    },
+    closeUploadFile() {
+      this.uploadFileList = []
+    },
+    // 开始上传文件
+    startUpload() {
+      this.uploadFileList.forEach((file) => {
+        const formData = new FormData()
+        formData.append('file', file)
+        formData.append('id', this.reqDataId)
+        formData.append('allowPathParent', this.tempNode.allowPathParent)
+        formData.append('unzip', this.uploadFileZip)
+        formData.append('nextPath', this.tempNode.nextPath)
+        this.confirmLoading = true
+        // 上传文件
+        uploadFile(this.baseUrl, formData)
+          .then((res) => {
+            if (res.code === 200) {
+              $notification.success({
+                message: res.msg
+              })
+              this.loadFileList()
+              this.closeUploadFile()
+              this.uploadFileVisible = false
+            }
+          })
+          .finally(() => {
+            this.confirmLoading = false
+          })
+      })
+    },
+    startUploadSharding() {
+      // 设置上传状态
+      this.confirmLoading = true
+      // 遍历上传文件
+      concurrentExecution(
+        this.uploadFileList.map((item, index) => {
+          // console.log(item);
+          return index
+        }),
+        // 并发数只能是 1
+        1,
+        (curItem) => {
+          const file = this.uploadFileList[curItem]
+          this.uploadFileList = this.uploadFileList.map((fileItem, fileIndex) => {
+            if (fileIndex === curItem) {
+              fileItem.status = 'uploading'
+            }
+            return fileItem
+          })
+          this.percentage = 0
+          this.percentageInfo = {}
+          return new Promise((resolve, reject) => {
+            uploadPieces({
+              /** ssh 文件上传 目前切片并发控制为1 */
+              concurrentNum: 1,
+              file,
+              resolveFileProcess: (msg) => {
+                this.globalLoading({
+                  spinning: true,
+                  tip: msg
+                })
+              },
+              resolveFileEnd: () => {
+                this.globalLoading(false)
+              },
+              process: (process, end, total, duration) => {
+                this.percentage = Math.max(this.percentage, process)
+                this.percentageInfo = { end, total, duration }
+              },
+              success: () => {
+                // 合并
+                $notification.success({
+                  message: this.$t('i18n_a7699ba731')
+                })
+                this.uploadFileList = this.uploadFileList.map((fileItem, fileIndex) => {
+                  if (fileIndex === curItem) {
+                    fileItem.status = 'done'
+                  }
+                  return fileItem
+                })
+
+                resolve()
+              },
+              uploadChunkError: () => {
+                this.confirmLoading = false
+                this.percentage = 0
+                this.percentageInfo = {}
+                this.uploadFileList = []
+              },
+              error: (msg) => {
+                this.uploadFileList = this.uploadFileList.map((fileItem, fileIndex) => {
+                  if (fileIndex === curItem) {
+                    fileItem.status = 'error'
+                  }
+                  return fileItem
+                })
+                $notification.error({
+                  message: msg
+                })
+                this.confirmLoading = false
+                this.percentage = 0
+                this.percentageInfo = {}
+                reject()
+              },
+              uploadCallback: (formData) => {
+                return new Promise((resolve, reject) => {
+                  formData.append('id', this.reqDataId)
+                  formData.append('allowPathParent', this.tempNode.allowPathParent)
+                  formData.append('unzip', this.uploadFileZip)
+                  formData.append('nextPath', this.tempNode.nextPath)
+
+                  // 上传文件
+                  uploadShardingFile(this.baseUrl, formData)
+                    .then((res) => {
+                      if (res.code === 200) {
+                        resolve()
+                      } else {
+                        reject()
+                      }
+                    })
+                    .catch(() => {
+                      reject()
+                    })
+                })
+              }
+            })
+          })
+        }
+      )
+        .then(() => {
+          //this.uploading = this.successSize !== this.uploadFileList.length
+          // // 判断是否全部上传完成
+          // if (!this.uploading) {
+          //   this.uploadFileList = []
+          //   setTimeout(() => {
+          //     this.loadFileList()
+          //     this.uploadFileVisible = false
+          //   }, 2000)
+          // }
+          this.percentage = 0
+          this.percentageInfo = {}
+          this.uploadFileList = []
+          this.loadFileList()
+          this.uploadShardingFileVisible = false
+        })
+        .finally(() => {
+          this.confirmLoading = false
+          //
+        })
+    },
+    // 编辑
+    handleEdit(record) {
+      this.temp = Object.assign({}, record)
+      const params = {
+        id: this.reqDataId,
+        allowPathParent: record.allowPathParent,
+        nextPath: record.nextPath,
+        name: record.name
+      }
+      readFile(this.baseUrl, params).then((res) => {
+        if (res.code === 200) {
+          this.temp = { ...this.temp, fileContent: res.data }
+          this.editFileVisible = true
+        }
+      })
+      //
+    },
+    updateFileData() {
+      const params = {
+        id: this.reqDataId,
+        allowPathParent: this.temp.allowPathParent,
+        nextPath: this.temp.nextPath,
+        name: this.temp.name,
+        content: this.temp.fileContent
+      }
+      this.confirmLoading = true
+      updateFileData(this.baseUrl, params)
+        .then((res) => {
+          if (res.code === 200) {
+            $notification.success({
+              message: res.msg
+            })
+            this.editFileVisible = false
+          }
+        })
+        .finally(() => {
+          this.confirmLoading = false
+        })
+    },
+    // 修改文件权限
+    handleFilePermission(record) {
+      this.temp = Object.assign({}, record)
+      this.permissions = parsePermissions(this.temp.permissions)
+      //const permissionsValue = calcFilePermissionValue(this.permissions);
+      //this.permissionTips = `cd ${this.temp.nextPath} && chmod ${permissionsValue} ${this.temp.name}`;
+      this.editFilePermissionVisible = true
+    },
+    // 更新文件权限提示
+    renderFilePermissionsTips() {
+      //const permissionsValue = calcFilePermissionValue(this.permissions);
+      //this.permissionTips = `cd ${this.temp.nextPath} && chmod ${permissionsValue} ${this.temp.name}`;
+    }, // 确认修改文件权限
+    updateFilePermissions() {
+      // 请求参数
+      const params = {
+        id: this.reqDataId,
+        allowPathParent: this.temp.allowPathParent,
+        nextPath: this.temp.nextPath,
+        fileName: this.temp.name,
+        permissionValue: calcFilePermissionValue(this.permissions)
+      }
+      changeFilePermission(this.baseUrl, params).then((res) => {
+        if (res.code === 200) {
+          $notification.success({
+            message: res.msg
+          })
+          this.editFilePermissionVisible = false
+          this.loadFileList()
+        }
+      })
+    },
+
+    // 下载
+    handleDownload(record) {
+      // 请求参数
+      const params = {
+        id: this.reqDataId,
+        allowPathParent: record.allowPathParent,
+        nextPath: record.nextPath,
+        name: record.name
+      }
+      // 请求接口拿到 blob
+      window.open(downloadFile(this.baseUrl, params), '_blank')
+    },
+    // 删除文件夹
+    handleDeletePath() {
+      $confirm({
+        title: this.$t('i18n_c4535759ee'),
+        zIndex: 1009,
+        content: this.$t('i18n_8756efb8f4'),
+        okText: this.$t('i18n_e83a256e4f'),
+        cancelText: this.$t('i18n_625fb26b4b'),
+        onOk: async () => {
+          return deleteFile(this.baseUrl, {
+            id: this.reqDataId,
+            allowPathParent: this.tempNode.allowPathParent,
+            nextPath: this.tempNode.nextPath
+          }).then((res) => {
+            if (res.code === 200) {
+              $notification.success({
+                message: res.msg
+              })
+              // 刷新树
+              const activeKey = this.tempNode.activeKey
+              // 获取上一级节点
+              const parentNode = this.getTreeNode(activeKey.slice(0, activeKey.length - 1))
+              // 设置当前选中
+              this.selectedKeys = [parentNode.key]
+              // 设置缓存节点
+              this.tempNode = parentNode
+              // 加载上一级文件列表
+              this.loadTreeNode()
+
+              this.fileList = []
+              //this.loadFileList();
+            }
+          })
+        }
+      })
+    },
+    // 删除
+    handleDelete(record) {
+      $confirm({
+        title: this.$t('i18n_c4535759ee'),
+        zIndex: 1009,
+        content: this.$t('i18n_3a6bc88ce0'),
+        okText: this.$t('i18n_e83a256e4f'),
+        cancelText: this.$t('i18n_625fb26b4b'),
+        onOk: () => {
+          return deleteFile(this.baseUrl, {
+            id: this.reqDataId,
+            allowPathParent: record.allowPathParent,
+            nextPath: record.nextPath,
+            name: record.name
+          }).then((res) => {
+            if (res.code === 200) {
+              $notification.success({
+                message: res.msg
+              })
+              this.loadFileList()
+            }
+          })
+        }
+      })
+    },
+    handleRenameFile(record) {
+      this.renameFileFolderVisible = true
+      this.temp = {
+        fileFolderName: record.name,
+        oldFileFolderName: record.name,
+        allowPathParent: record.allowPathParent,
+        nextPath: record.nextPath
+      }
+    },
+    // 确认修改文件 目录名称
+    renameFileFolder() {
+      const params = {
+        id: this.reqDataId,
+        name: this.temp.oldFileFolderName,
+        newname: this.temp.fileFolderName,
+        allowPathParent: this.temp.allowPathParent,
+        nextPath: this.temp.nextPath
+      }
+      this.confirmLoading = true
+      renameFileFolder(this.baseUrl, params)
+        .then((res) => {
+          if (res.code === 200) {
+            $notification.success({
+              message: res.msg
+            })
+            this.renameFileFolderVisible = false
+            this.loadFileList()
+          }
+        })
+        .finally(() => {
+          this.confirmLoading = false
+        })
+    }
+  }
+}
+</script>
+<style lang="less" scoped>
+:deep(.ant-progress-text) {
+  width: auto;
+}
+.ssh-file-layout {
+  padding: 0;
+  min-height: calc(100vh - 75px);
+}
+.dir-container {
+  padding: 10px;
+  border-bottom: 1px solid #eee;
+}
+.sider {
+  border: 1px solid #e2e2e2;
+  /* overflow-x: auto; */
+}
+.file-content {
+  margin: 10px 10px 0;
+  padding: 10px;
+  /* background-color: #fff; */
+}
+.title {
+  font-weight: 600;
+  font-size: larger;
+}
+.tree-container {
+  overflow-x: auto;
+  :deep(.ant-tree-title) {
+    word-break: keep-all;
+    white-space: nowrap;
+  }
+  :deep(.ant-tree-node-content-wrapper) {
+    display: flex;
+    align-items: center;
+  }
+  :deep(.ant-tree.ant-tree-directory .ant-tree-treenode .ant-tree-node-content-wrapper.ant-tree-node-selected) {
+    background-color: #1677ff;
+  }
+}
+</style>

+ 604 - 0
web-vue/src/pages/ftp/ftp.vue

@@ -0,0 +1,604 @@
+<template>
+  <div>
+    <template v-if="useSuggestions">
+      <a-result title="当前工作空间还没有FTP" sub-title="请到【系统管理】-> 【资产管理】-> 【FTP管理】新增FTP,或者将已新增的FTP授权关联、分配到此工作空间">
+        <template #extra>
+          <router-link to="/system/assets/ftp-list">
+            <a-button key="console" type="primary">{{ $t('i18n_6dcf6175d8') }}</a-button></router-link
+          >
+        </template>
+      </a-result>
+    </template>
+    <!-- 数据表格 -->
+    <CustomTable
+      v-else
+      is-show-tools
+      default-auto-refresh
+      :auto-refresh-time="5"
+      table-name="ftp-list"
+      :empty-description="$t('i18n_a9795c06c8')"
+      :active-page="activePage"
+      :data-source="list"
+      :columns="columns"
+      size="middle"
+      :pagination="pagination"
+      bordered
+      row-key="id"
+      :row-selection="rowSelection"
+      :scroll="{
+        x: 'max-content'
+      }"
+      @change="changePage"
+      @refresh="loadData"
+    >
+      <template #title>
+        <a-space wrap class="search-box">
+          <a-input
+            v-model:value="listQuery['%name%']"
+            class="search-input-item"
+            placeholder="ftp名称"
+            @press-enter="loadData"
+          />
+          <a-select
+            v-model:value="listQuery.group"
+            show-search
+            :filter-option="
+              (input, option) => {
+                const children = option.children && option.children()
+                return (
+                  children &&
+                  children[0].children &&
+                  children[0].children.toLowerCase().indexOf(input.toLowerCase()) >= 0
+                )
+              }
+            "
+            allow-clear
+            :placeholder="$t('i18n_829abe5a8d')"
+            class="search-input-item"
+          >
+            <a-select-option v-for="item in groupList" :key="item">{{ item }}</a-select-option>
+          </a-select>
+          <a-tooltip :title="$t('i18n_4838a3bd20')">
+            <a-button type="primary" :loading="loading" @click="loadData">{{ $t('i18n_e5f71fc31e') }}</a-button>
+          </a-tooltip>
+
+          <a-button
+            type="primary"
+            :disabled="!tableSelections || !tableSelections.length"
+            @click="syncToWorkspaceShow"
+            >{{ $t('i18n_398ce396cd') }}</a-button
+          >
+        </a-space>
+      </template>
+
+<!--      <template #tableHelp>
+        <a-tooltip>
+          <template #title>
+            <div>
+              <ul>
+                <li>{{ $t('i18n_a13d8ade6a') }}</li>
+                <li>{{ $t('i18n_fda92d22d9') }}</li>
+                <li>{{ $t('i18n_1278df0cfc') }}</li>
+              </ul>
+            </div>
+          </template>
+          <QuestionCircleOutlined />
+        </a-tooltip>
+      </template>-->
+
+      <template #tableBodyCell="{ column, text, record }">
+        <template v-if="column.tooltip">
+          <a-tooltip :title="text"> {{ text }}</a-tooltip>
+        </template>
+        <template v-else-if="column.dataIndex === 'host'">
+          <a-tooltip
+            :title="`${record.machineFtp && record.machineFtp.host}:${record.machineFtp && record.machineFtp.port}`"
+          >
+            {{ record.machineFtp && record.machineFtp.host }}:{{ record.machineFtp && record.machineFtp.port }}
+          </a-tooltip>
+        </template>
+        <template v-else-if="column.dataIndex instanceof Array && column.dataIndex.includes('status')">
+          <a-tooltip :title="record.machineFtp && record.machineFtp.statusMsg">
+            <a-tag
+              :color="
+                statusMap[record.machineFtp && record.machineFtp.status] &&
+                statusMap[record.machineFtp && record.machineFtp.status].color
+              "
+            >
+              {{
+                (statusMap[record.machineFtp && record.machineFtp.status] &&
+                  statusMap[record.machineFtp && record.machineFtp.status].desc) ||
+                $t('i18n_1622dc9b6b')
+              }}
+            </a-tag>
+          </a-tooltip>
+        </template>
+
+        <template v-else-if="column.dataIndex === 'operation'">
+          <a-space>
+<!--            <a-dropdown>
+              <a-button size="small" type="primary" @click="handleTerminal(record, false)"
+                >{{ $t('i18n_4722bc0c56') }}<DownOutlined
+              /></a-button>
+              <template #overlay>
+                <a-menu>
+                  <a-menu-item key="1">
+                    <a-button size="small" type="primary" @click="handleTerminal(record, true)"
+                      ><FullscreenOutlined />{{ $t('i18n_a3296ef4f6') }}</a-button
+                    >
+                  </a-menu-item>
+                  <a-menu-item key="2">
+                    <router-link
+                      target="_blank"
+                      :to="{
+                        path: '/full-terminal',
+                        query: { id: record.id, wid: getWorkspaceId() }
+                      }"
+                    >
+                      <a-button size="small" type="primary">
+                        <FullscreenOutlined />{{ $t('i18n_0934f7777a') }}</a-button
+                      >
+                    </router-link>
+                  </a-menu-item>
+                </a-menu>
+              </template>
+            </a-dropdown>-->
+            <template v-if="record.fileDirs">
+              <a-button size="small" type="primary" @click="handleFile(record)">{{ $t('i18n_2a0c4740f1') }}</a-button>
+            </template>
+            <template v-else>
+              <a-tooltip placement="topLeft" title="如果按钮不可用,请去资产管理 ftp 列表的关联中新增当前工作空间允许管理的授权文件夹">
+                <a-button size="small" type="primary" :disabled="true">{{ $t('i18n_2a0c4740f1') }}</a-button>
+              </a-tooltip>
+            </template>
+
+            <a-tooltip placement="topLeft" title="如果按钮不可用,请去资产管理 ftp 列表的关联中新增当前工作空间允许管理的授权文件夹">
+              <a-button size="small" type="primary" @click="handleEdit(record)">{{
+                  $t('i18n_95b351c862')
+                }}</a-button>
+            </a-tooltip>
+            <a-tooltip placement="topLeft" title="如果按钮不可用,请去资产管理 ftp 列表的关联中新增当前工作空间允许管理的授权文件夹">
+              <a-button size="small" type="primary" danger @click="handleDelete(record)">{{
+                  $t('i18n_2f4aaddde3')
+                }}</a-button>
+            </a-tooltip>
+
+<!--            <a-dropdown>
+              <a @click="(e) => e.preventDefault()">
+                {{ $t('i18n_0ec9eaf9c3') }}
+                <DownOutlined />
+              </a>
+              <template #overlay>
+                <a-menu>
+                  <a-menu-item>
+                    <a-button size="small" type="primary" @click="handleEdit(record)">{{
+                      $t('i18n_95b351c862')
+                    }}</a-button>
+                  </a-menu-item>
+                  <a-menu-item>
+                    <a-button size="small" type="primary" danger @click="handleDelete(record)">{{
+                      $t('i18n_2f4aaddde3')
+                    }}</a-button>
+                  </a-menu-item>
+                  <a-menu-item>
+                    <a-button size="small" type="primary" @click="handleViewLog(record)">{{
+                      $t('i18n_3ed3733078')
+                    }}</a-button>
+                  </a-menu-item>
+                </a-menu>
+              </template>
+            </a-dropdown>-->
+
+          </a-space>
+        </template>
+      </template>
+    </CustomTable>
+    <!-- 编辑区 -->
+    <CustomModal
+      v-if="editFtpVisible"
+      v-model:open="editFtpVisible"
+      destroy-on-close
+      width="600px"
+      title="编辑 FTP"
+      :mask-closable="false"
+      :confirm-loading="confirmLoading"
+      @ok="handleEditSshOk"
+    >
+      <a-form ref="editFtpForm" :rules="rules" :model="temp" :label-col="{ span: 4 }" :wrapper-col="{ span: 18 }">
+        <template v-if="getUserInfo && getUserInfo.systemUser">
+          <a-alert type="info" show-icon style="width: 100%; margin-bottom: 10px">
+            <template #message>
+              <ul>
+                <li>{{ "此编辑仅能编辑当前 FTP 在此工作空间的名称信" }}</li>
+                <li>{{ "如果要配置 FTP 请到【系统管理】-> 【资产管理】-> 【FTP 管理】中去配置。" }}</li>
+                <li>{{ "当前 FTP 的授权目录(文件目录、文件后缀)需要请到 【系统管理】-> 【资产管理】-> 【FTP 管理】-> 操作栏中->关联按钮->对应工作空间->操作栏中->配置按钮" }}</li>
+              </ul>
+            </template>
+          </a-alert>
+        </template>
+        <a-form-item label="FTP 名称" name="name">
+          <a-input v-model:value="temp.name" :max-length="50" placeholder="FTP 名称" />
+        </a-form-item>
+        <a-form-item :label="$t('i18n_1014b33d22')" name="group">
+          <custom-select
+            v-model:value="temp.group"
+            :data="groupList"
+            :input-placeholder="$t('i18n_bd0362bed3')"
+            :select-placeholder="$t('i18n_9cac799f2f')"
+          >
+            <template #suffix>
+              <a-tooltip>
+                <template #title>
+                  <div>
+                    {{ $t('i18n_bd7c7abc8c') }}
+                  </div>
+                </template>
+                <QuestionCircleOutlined />
+              </a-tooltip>
+            </template>
+          </custom-select>
+        </a-form-item>
+      </a-form>
+    </CustomModal>
+
+    <!-- 文件管理 -->
+    <CustomDrawer
+      v-if="drawerVisible"
+      destroy-on-close
+      :open="drawerVisible"
+      :title="`${temp.name} ${$t('i18n_8780e6b3d1')}`"
+      placement="right"
+      width="90vw"
+      @close="
+        () => {
+          drawerVisible = false
+        }
+      "
+    >
+      <ftp-file v-if="drawerVisible" :ftp-id="temp.id" />
+    </CustomDrawer>
+
+    <!-- 同步到其他工作空间 -->
+    <CustomModal
+      v-if="syncToWorkspaceVisible"
+      v-model:open="syncToWorkspaceVisible"
+      destroy-on-close
+      :title="$t('i18n_1a44b9e2f7')"
+      :confirm-loading="confirmLoading"
+      :mask-closable="false"
+      @ok="handleSyncToWorkspace"
+    >
+      <a-alert :message="$t('i18n_947d983961')" type="warning" show-icon>
+        <template #description>
+          <ul>
+            <li>{{ $t('i18n_59a15a0848') }}</li>
+            <li>{{ $t('i18n_412504968d') }}</li>
+            <li>{{ $t('i18n_57b7990b45') }}</li>
+          </ul>
+        </template>
+      </a-alert>
+      <a-form :model="temp" :label-col="{ span: 6 }" :wrapper-col="{ span: 14 }">
+        <a-form-item> </a-form-item>
+        <a-form-item :label="$t('i18n_b4a8c78284')" name="workspaceId">
+          <a-select
+            v-model:value="temp.workspaceId"
+            show-search
+            :filter-option="
+              (input, option) => {
+                const children = option.children && option.children()
+                return (
+                  children &&
+                  children[0].children &&
+                  children[0].children.toLowerCase().indexOf(input.toLowerCase()) >= 0
+                )
+              }
+            "
+            :placeholder="$t('i18n_b3bda9bf9e')"
+          >
+            <a-select-option v-for="item in workspaceList" :key="item.id" :disabled="getWorkspaceId() === item.id">{{
+              item.name
+            }}</a-select-option>
+          </a-select>
+        </a-form-item>
+      </a-form>
+    </CustomModal>
+  </div>
+</template>
+<script>
+import { deleteFtp, editFtp, getFtpList, syncToWorkspace, getFtpGroupAll } from '@/api/ftp'
+import { statusMap } from '@/api/system/assets-ssh'
+import FtpFile from '@/pages/ftp/ftp-file'
+import Terminal1 from '@/pages/ssh/terminal'
+import {
+  CHANGE_PAGE,
+  COMPUTED_PAGINATION,
+  PAGE_DEFAULT_LIST_QUERY,
+  parseTime,
+  renderSize,
+  formatDuration,
+} from '@/utils/const'
+import { getWorkSpaceListAll } from '@/api/workspace'
+
+import { mapState } from 'pinia'
+import { useUserStore } from '@/stores/user'
+import { useAppStore } from '@/stores/app'
+import OperationLog from '@/pages/system/assets/ssh/operation-log'
+import CustomSelect from '@/components/customSelect'
+
+export default {
+  components: {
+    FtpFile,
+    Terminal1,
+    OperationLog,
+    CustomSelect
+  },
+  data() {
+    return {
+      loading: true,
+      list: [],
+      temp: {},
+      listQuery: Object.assign({}, PAGE_DEFAULT_LIST_QUERY),
+      editFtpVisible: false,
+
+      syncToWorkspaceVisible: false,
+      tableSelections: [],
+      workspaceList: [],
+      tempNode: {},
+      // fileList: [],
+      statusMap,
+
+      drawerVisible: false,
+      columns: [
+        {
+          title: this.$t('i18n_d7ec2d3fea'),
+          dataIndex: 'name',
+          sorter: true,
+          width: 100,
+          ellipsis: true,
+          tooltip: true
+        },
+
+        {
+          title: 'Host',
+          dataIndex: ['machineFtp', 'host'],
+          width: 100,
+          ellipsis: true
+        },
+        // { title: "Port", dataIndex: "machineFtp.port", sorter: true, width: 80, ellipsis: true, },
+        {
+          title: this.$t('i18n_819767ada1'),
+          dataIndex: ['machineFtp', 'user'],
+          width: '100px',
+          ellipsis: true
+        },
+
+        {
+          title: "端口",
+          dataIndex: ['machineFtp', 'port'],
+          width: 80,
+          ellipsis: true
+        },
+
+        // { title: "编码格式", dataIndex: "charset", sorter: true, width: 120, ellipsis: true,  },
+        {
+          title: this.$t('i18n_7912615699'),
+          dataIndex: ['machineFtp', 'status'],
+          ellipsis: true,
+          align: 'center',
+          width: '90px'
+        },
+        // { title: "编码格式", dataIndex: "machineFtp.charset", sorter: true, width: 120, ellipsis: true, },
+        {
+          title: this.$t('i18n_eca37cb072'),
+          dataIndex: 'createTimeMillis',
+          ellipsis: true,
+          sorter: true,
+          customRender: ({ text }) => parseTime(text),
+          width: '170px'
+        },
+        {
+          title: this.$t('i18n_1303e638b5'),
+          dataIndex: 'modifyTimeMillis',
+          sorter: true,
+          ellipsis: true,
+          customRender: ({ text }) => parseTime(text),
+          width: '170px'
+        },
+        {
+          title: this.$t('i18n_2b6bc0f293'),
+          dataIndex: 'operation',
+
+          width: '200px',
+          align: 'center',
+          // ellipsis: true,
+          fixed: 'right'
+        }
+      ],
+
+      // 表单校验规则
+      rules: {
+        name: [{ required: true, message: this.$t('i18n_9f6fa346d8'), trigger: 'blur' }]
+      },
+
+      groupList: [],
+      confirmLoading: false
+    }
+  },
+  computed: {
+    ...mapState(useUserStore, ['getUserInfo']),
+    ...mapState(useAppStore, ['getWorkspaceId']),
+    pagination() {
+      return COMPUTED_PAGINATION(this.listQuery)
+    },
+    rowSelection() {
+      return {
+        onChange: (selectedRowKeys) => {
+          this.tableSelections = selectedRowKeys
+        },
+        selectedRowKeys: this.tableSelections
+      }
+    },
+    activePage() {
+      return this.$attrs.routerUrl === this.$route.path
+    },
+    useSuggestions() {
+      if (this.loading) {
+        // 加载中不提示
+        return false
+      }
+      if (!this.getUserInfo || !this.getUserInfo.systemUser) {
+        // 没有登录或者不是超级管理员
+        return false
+      }
+      if (this.listQuery.page !== 1 || this.listQuery.total > 0) {
+        // 不是第一页 或者总记录数大于 0
+        return false
+      }
+      // 判断是否存在搜索条件
+      const nowKeys = Object.keys(this.listQuery)
+      const defaultKeys = Object.keys(PAGE_DEFAULT_LIST_QUERY)
+      const dictOrigin = nowKeys.filter((item) => !defaultKeys.includes(item))
+      return dictOrigin.length === 0
+    }
+  },
+  created() {
+    this.loadData()
+    this.loadGroupList()
+  },
+  methods: {
+    renderSize,
+    formatDuration,
+    // 加载数据
+    loadData(pointerEvent) {
+      this.loading = true
+      this.listQuery.page = pointerEvent?.altKey || pointerEvent?.ctrlKey ? 1 : this.listQuery.page
+      getFtpList(this.listQuery).then((res) => {
+        if (res.code === 200) {
+          this.list = res.data.result
+          this.listQuery.total = res.data.total
+          //
+        }
+        this.loading = false
+      })
+    },
+    // 获取所有的分组
+    loadGroupList() {
+      getFtpGroupAll().then((res) => {
+        if (res.data) {
+          this.groupList = res.data
+        }
+      })
+    },
+    // 修改
+    handleEdit(record) {
+      this.temp = Object.assign({}, { id: record.id, group: record.group, name: record.name })
+
+      this.editFtpVisible = true
+      // @author jzy 08-04
+      this.$refs['editFtpForm'] && this.$refs['editFtpForm'].resetFields()
+    },
+    // 提交 FTP 数据
+    handleEditSshOk() {
+      // 检验表单
+      this.$refs['editFtpForm'].validate().then(() => {
+        // 提交数据
+        this.confirmLoading = true
+        editFtp(this.temp)
+          .then((res) => {
+            if (res.code === 200) {
+              $notification.success({
+                message: res.msg
+              })
+              //this.$refs['editFtpForm'].resetFields();
+              // this.fileList = [];
+              this.editFtpVisible = false
+              this.loadData()
+              this.loadGroupList()
+            }
+          })
+          .finally(() => {
+            this.confirmLoading = false
+          })
+      })
+    },
+
+    // 文件管理
+    handleFile(record) {
+      this.temp = Object.assign({}, { id: record.id, group: record.group, name: record.name })
+
+      this.drawerVisible = true
+    },
+    // 删除
+    handleDelete(record) {
+      $confirm({
+        title: this.$t('i18n_c4535759ee'),
+        zIndex: 1009,
+        content: "真的要删除 FTP 么?",
+        okText: this.$t('i18n_e83a256e4f'),
+        cancelText: this.$t('i18n_625fb26b4b'),
+        onOk: () => {
+          return deleteFtp(record.id).then((res) => {
+            if (res.code === 200) {
+              $notification.success({
+                message: res.msg
+              })
+              this.loadData()
+            }
+          })
+        }
+      })
+    },
+
+    // 分页、排序、筛选变化时触发
+    changePage(pagination, filters, sorter) {
+      this.listQuery = CHANGE_PAGE(this.listQuery, { pagination, sorter })
+      this.loadData()
+    },
+
+    // 加载工作空间数据
+    loadWorkSpaceListAll() {
+      getWorkSpaceListAll().then((res) => {
+        if (res.code === 200) {
+          this.workspaceList = res.data
+        }
+      })
+    },
+    // 同步到其他工作情况
+    syncToWorkspaceShow() {
+      this.syncToWorkspaceVisible = true
+      this.loadWorkSpaceListAll()
+      this.temp = {
+        workspaceId: undefined
+      }
+    },
+    //
+    handleSyncToWorkspace() {
+      if (!this.temp.workspaceId) {
+        $notification.warn({
+          message: this.$t('i18n_b3bda9bf9e')
+        })
+        return false
+      }
+      // 同步
+      this.confirmLoading = true
+      syncToWorkspace({
+        ids: this.tableSelections.join(','),
+        toWorkspaceId: this.temp.workspaceId
+      })
+        .then((res) => {
+          if (res.code === 200) {
+            $notification.success({
+              message: res.msg
+            })
+            this.tableSelections = []
+            this.syncToWorkspaceVisible = false
+            return false
+          }
+        })
+        .finally(() => {
+          this.confirmLoading = false
+        })
+    }
+  }
+}
+</script>

+ 973 - 0
web-vue/src/pages/system/assets/ftp/ftp-list.vue

@@ -0,0 +1,973 @@
+<template>
+  <div>
+    <CustomTable
+      is-show-tools
+      default-auto-refresh
+      :auto-refresh-time="5"
+      table-name="assets-ftp-list"
+      :empty-description="$t('i18n_13d10a9b78')"
+      :active-page="activePage"
+      :data-source="list"
+      :columns="columns"
+      size="middle"
+      :pagination="pagination"
+
+      row-key="id"
+      :row-selection="rowSelection"
+      :scroll="{
+            x: 'max-content'
+          }"
+      @change="changePage"
+      @refresh="loadData"
+    >
+      <template #title>
+        <a-space wrap class="search-box">
+          <a-input
+            v-model:value="listQuery['%name%']"
+            class="search-input-item"
+            placeholder="ftp名称"
+            @press-enter="loadData"
+          />
+          <a-input
+            v-model:value="listQuery['%host%']"
+            class="search-input-item"
+            placeholder="host"
+            @press-enter="loadData"
+          />
+          <a-select
+            v-model:value="listQuery.groupName"
+            show-search
+            :filter-option="
+                  (input, option) => {
+                    const children = option.children && option.children()
+                    return (
+                      children &&
+                      children[0].children &&
+                      children[0].children.toLowerCase().indexOf(input.toLowerCase()) >= 0
+                    )
+                  }
+                "
+            allow-clear
+            :placeholder="$t('i18n_829abe5a8d')"
+            class="search-input-item"
+          >
+            <a-select-option v-for="item in groupList" :key="item">{{ item }}</a-select-option>
+          </a-select>
+
+          <a-tooltip :title="$t('i18n_4838a3bd20')">
+            <a-button type="primary" :loading="loading" @click="loadData">{{ $t('i18n_e5f71fc31e') }}</a-button>
+          </a-tooltip>
+
+          <a-button type="primary" @click="handleAdd">{{ $t('i18n_66ab5e9f24') }}</a-button>
+          <a-button :disabled="!tableSelections.length" type="primary" @click="syncToWorkspaceShow()">
+            {{ $t('i18n_82d2c66f47') }}
+          </a-button
+          >
+          <a-button type="primary" @click="handlerExportData()"
+          >
+            <DownloadOutlined/>
+            {{ $t('i18n_55405ea6ff') }}
+          </a-button
+          >
+          <a-dropdown>
+            <template #overlay>
+              <a-menu>
+                <a-menu-item key="1">
+                  <a-button type="primary" @click="handlerImportTemplate()">{{ $t('i18n_2e505d23f7') }}</a-button>
+                </a-menu-item>
+              </a-menu>
+            </template>
+
+            <a-upload
+              name="file"
+              accept=".csv"
+              action=""
+              :show-upload-list="false"
+              :multiple="false"
+              :before-upload="beforeUpload"
+            >
+              <a-button type="primary">
+                <UploadOutlined/>
+                {{ $t('i18n_8d9a071ee2') }}
+                <DownOutlined/>
+              </a-button>
+            </a-upload>
+          </a-dropdown>
+        </a-space>
+      </template>
+      <template #tableHelp>
+        <a-tooltip>
+          <template #title>
+            <div>
+              <ul>
+                <li>{{ $t('i18n_cc3a8457ea') }}</li>
+                <li>{{ $t('i18n_c4b5d36ff0') }}</li>
+                <li>{{ $t('i18n_1278df0cfc') }}</li>
+              </ul>
+            </div>
+          </template>
+          <QuestionCircleOutlined/>
+        </a-tooltip>
+      </template>
+      <template #tableBodyCell="{ column, text, record }">
+        <template v-if="column.dataIndex === 'name'">
+          <a-tooltip :title="text">
+            <a-button style="padding: 0" type="link" size="small" @click="handleEdit(record)"> {{ text }}</a-button>
+          </a-tooltip>
+        </template>
+        <template v-else-if="column.tooltip">
+          <a-tooltip :title="text"> {{ text }}</a-tooltip>
+        </template>
+        <template v-else-if="column.dataIndex === 'host'">
+          <a-tooltip :title="`${text}:${record.port}`"> {{ text }}:{{ record.port }}</a-tooltip>
+        </template>
+
+
+        <template v-else-if="column.dataIndex === 'status'">
+          <a-tooltip :title="`${record.statusMsg || $t('i18n_77e100e462')}`">
+            <a-tag :color="statusMap[record.status] && statusMap[record.status].color">{{
+                (statusMap[record.status] && statusMap[record.status].desc) || $t('i18n_1622dc9b6b')
+              }}
+            </a-tag>
+          </a-tooltip>
+        </template>
+        <template v-else-if="column.dataIndex === 'renderSize'">
+          <a-tooltip placement="topLeft" :title="renderSize(text)">
+            <span>{{ renderSize(text) }}</span>
+          </a-tooltip>
+        </template>
+        <template v-else-if="column.dataIndex === 'operation'">
+          <a-space>
+            <a-button size="small" type="primary" @click="syncToWorkspaceShow(record)">{{
+                $t('i18n_e39de3376e')
+              }}
+            </a-button>
+            <a-button size="small" type="primary" @click="handleFile(record)">{{ $t('i18n_2a0c4740f1') }}</a-button>
+            <a-button size="small" type="primary" @click="handleViewWorkspaceFtp(record)">{{
+                $t('i18n_1c3cf7f5f0')
+              }}
+            </a-button>
+
+            <a-dropdown>
+              <a @click="(e) => e.preventDefault()">
+                {{ $t('i18n_0ec9eaf9c3') }}
+                <DownOutlined/>
+              </a>
+              <template #overlay>
+                <a-menu>
+                  <a-menu-item>
+                    <a-button size="small" type="primary" @click="handleEdit(record)">{{
+                        $t('i18n_95b351c862')
+                      }}
+                    </a-button>
+                  </a-menu-item>
+                  <a-menu-item>
+                    <a-button size="small" type="primary" danger @click="handleDelete(record)">{{
+                        $t('i18n_2f4aaddde3')
+                      }}
+                    </a-button>
+                  </a-menu-item>
+                  <!--                      <a-menu-item>
+                                          <a-button size="small" type="primary" @click="handleViewLog(record)">{{
+                                              $t('i18n_3ed3733078')
+                                            }}
+                                          </a-button>
+                                        </a-menu-item>-->
+                </a-menu>
+              </template>
+            </a-dropdown>
+          </a-space>
+        </template>
+      </template>
+    </CustomTable>
+    <!-- 编辑区 -->
+    <CustomModal
+      v-if="editFtpVisible"
+      v-model:open="editFtpVisible"
+      destroy-on-close
+      :confirm-loading="confirmLoading"
+      width="600px"
+      title="编辑FTP"
+      :mask-closable="false"
+      @ok="handleEditFtpOk"
+    >
+      <a-form ref="editFtpForm" :rules="rules" :model="temp" :label-col="{ span: 4 }" :wrapper-col="{ span: 18 }">
+        <a-form-item label="FTP名称" name="name">
+          <a-input v-model:value="temp.name" :max-length="50" placeholder="FTP名称"/>
+        </a-form-item>
+        <a-form-item :label="$t('i18n_1014b33d22')" name="group">
+          <custom-select
+            v-model:value="temp.groupName"
+            :data="groupList"
+            :input-placeholder="$t('i18n_bd0362bed3')"
+            :select-placeholder="$t('i18n_9cac799f2f')"
+          >
+            <template #suffix>
+              <a-tooltip>
+                <template #title>
+                  <div>
+                    {{ $t('i18n_bd7c7abc8c') }}
+                  </div>
+                  <div>
+                    {{ $t('i18n_f92d505ff5') }}
+                  </div>
+                </template>
+                <QuestionCircleOutlined/>
+              </a-tooltip>
+            </template>
+          </custom-select>
+        </a-form-item>
+        <a-form-item label="Host" name="host">
+          <a-input-group compact name="host">
+            <a-input v-model:value="temp.host" style="width: 70%" :placeholder="$t('i18n_3d83a07747')"/>
+            <a-form-item-rest>
+              <a-input-number
+                v-model:value="temp.port"
+                style="width: 30%"
+                :min="1"
+                :placeholder="$t('i18n_39c7644388')"
+              />
+            </a-form-item-rest>
+          </a-input-group>
+        </a-form-item>
+        <a-form-item label="传输模式" name="mode">
+          <a-radio-group v-model:value="temp.mode" :options="options"/>
+        </a-form-item>
+        <a-form-item name="user">
+          <template #label>
+            <a-tooltip>
+              {{ $t('i18n_819767ada1') }}
+              <template #title>
+                {{ $t('i18n_f0a1428f65') }}<b>$ref.wEnv.xxxx</b> xxxx {{ $t('i18n_c1b72e7ded') }}
+              </template
+              >
+              <QuestionCircleOutlined v-if="!temp.id"/>
+            </a-tooltip>
+          </template>
+          <a-input v-model:value="temp.user" :placeholder="$t('i18n_1fd02a90c3')">
+            <template #suffix>
+              <a-tooltip v-if="temp.id" title="密码字段在编辑的时候不会返回,如果需要重置或者清空就请点我">
+                <a-button size="small" type="primary" danger @click="handerRestHideField(temp)">{{
+                    $t('i18n_4403fca0c0')
+                  }}
+                </a-button>
+              </a-tooltip>
+            </template>
+          </a-input>
+        </a-form-item>
+
+        <!-- 新增时需要填写 -->
+        <!--				<a-form-item v-if="temp.type === 'add'" label="Password" name="password">-->
+        <!--					<a-input-password v-model="temp.password" placeholder="密码"/>-->
+        <!--				</a-form-item>-->
+        <!-- 修改时可以不填写 -->
+        <a-form-item
+          :name="`${temp.type === 'add' ? 'password' : 'password-update'}`"
+        >
+          <template #label>
+            <a-tooltip>
+              {{ $t('i18n_a810520460') }}
+              <template #title>
+                {{ $t('i18n_63dd96a28a') }}<b>$ref.wEnv.xxxx</b> xxxx {{ $t('i18n_c1b72e7ded') }}
+              </template
+              >
+              <QuestionCircleOutlined v-if="!temp.id"/>
+            </a-tooltip>
+          </template>
+          <!-- <a-input-password v-model="temp.password" :placeholder="`${temp.type === 'add' ? '密码' : '密码若没修改可以不用填写'}`" /> -->
+          <custom-input
+            :input="temp.password"
+            :env-list="envVarList"
+            :placeholder="`${temp.type === 'add' ? $t('i18n_a810520460') : $t('i18n_6c08692a3a')}`"
+            @change="
+                  (v) => {
+                    temp = { ...temp, password: v }
+                  }
+                "
+          >
+          </custom-input>
+        </a-form-item>
+        <a-form-item label="服务器语言" name="serverLanguageCode">
+          <a-input v-model:value="temp.serverLanguageCode" placeholder="服务器语言"/>
+        </a-form-item>
+        <a-form-item label="系统关键词" name="systemKey">
+          <a-input v-model:value="temp.systemKey" placeholder="服务器系统关键词"/>
+        </a-form-item>
+        <a-form-item :label="$t('i18n_6143a714d0')" name="charset">
+          <a-input v-model:value="temp.charset" :placeholder="$t('i18n_6143a714d0')"/>
+        </a-form-item>
+        <a-form-item :label="$t('i18n_67425c29a5')" name="timeout">
+          <a-input-number
+            v-model:value="temp.timeout"
+            :min="1"
+            :placeholder="$t('i18n_cb156269db')"
+            style="width: 100%"
+          />
+        </a-form-item>
+        <a-form-item :label="$t('i18n_649231bdee')" name="suffix">
+          <template #help>
+            {{ "此配置仅对服务端管理生效, 工作空间的 ftp 配置需要单独配置" }}<span style="color: red">{{ "配置方式:FTP管理->操作栏中->关联按钮->对应工作空间->操作栏中->配置按钮" }}</span>
+          </template>
+          <a-textarea
+            v-model:value="temp.allowEditSuffix"
+            :rows="5"
+            style="resize: none"
+            :placeholder="$t('i18n_01081f7817')"
+          />
+        </a-form-item>
+      </a-form>
+    </CustomModal>
+
+    <!-- 文件管理 -->
+    <CustomDrawer
+      v-if="drawerVisible"
+      destroy-on-close
+      :title="`${temp.name} ${$t('i18n_8780e6b3d1')}`"
+      placement="right"
+      width="90vw"
+      :open="drawerVisible"
+      @close="
+            () => {
+              drawerVisible = false
+            }
+          "
+    >
+      <ftp-file v-if="drawerVisible" :machine-ftp-id="temp.id"/>
+    </CustomDrawer>
+
+    <!-- 查看 ftp 关联工作空间的信息 -->
+    <CustomModal
+      v-if="viewWorkspaceFtp"
+      v-model:open="viewWorkspaceFtp"
+      destroy-on-close
+      width="50%"
+      title="关联工作空间ftp"
+      :footer="null"
+      :mask-closable="false"
+    >
+      <a-space direction="vertical" style="width: 100%">
+        <a-alert
+          v-if="workspaceFtpList && workspaceFtpList.length"
+          message="已经分配到工作空间的 FTP 无法直接删除,需要到分配到的各个工作空间逐一删除后才能删除资产 FTP"
+          type="info"
+          show-icon
+        />
+        <a-list bordered :data-source="workspaceFtpList">
+          <template #renderItem="{ item }">
+            <a-list-item style="display: block">
+              <a-row>
+                <a-col :span="10">FTP{{ $t('i18n_5b47861521') }}{{ item.name }}</a-col>
+                <a-col :span="10">{{ $t('i18n_2358e1ef49') }}{{ item.workspace && item.workspace.name }}</a-col>
+                <a-col :span="4">
+                  <a-button v-if="item.workspace" size="small" type="primary" @click="configWorkspaceFtp(item)"
+                  >{{ $t('i18n_224e2ccda8') }}
+                  </a-button>
+                  <a-button v-else size="small" type="primary" danger @click="handleDeleteWorkspaceItem(item)"
+                  >{{ $t('i18n_2f4aaddde3') }}
+                  </a-button>
+                </a-col>
+              </a-row>
+            </a-list-item>
+          </template>
+        </a-list>
+      </a-space>
+    </CustomModal>
+    <CustomModal
+      v-if="configWorkspaceFtpVisible"
+      v-model:open="configWorkspaceFtpVisible"
+      destroy-on-close
+      :confirm-loading="confirmLoading"
+      width="50%"
+      title="配置ftp"
+      :mask-closable="false"
+      @ok="handleConfigWorkspaceFtpOk"
+    >
+      <a-form
+        ref="editConfigWorkspaceFtpForm"
+        :rules="rules"
+        :model="temp"
+        :label-col="{ span: 4 }"
+        :wrapper-col="{ span: 18 }"
+      >
+        <a-form-item label="" :label-col="{ span: 0 }" :wrapper-col="{ span: 24 }">
+          <a-alert :message="$t('i18n_ce7e6e0ea9')" banner/>
+        </a-form-item>
+        <a-form-item label="FTP 名称">
+          <a-input
+            v-model:value="temp.name"
+            :disabled="true"
+            :max-length="50"
+            placeholder="FTP 名称"
+          />
+        </a-form-item>
+        <a-form-item :label="$t('i18n_6a588459d0')">
+          <a-input
+            v-model:value="temp.workspaceName"
+            :disabled="true"
+            :max-length="50"
+            :placeholder="$t('i18n_6a588459d0')"
+          />
+        </a-form-item>
+
+        <a-form-item name="fileDirs">
+          <template #label>
+            <a-tooltip>
+              {{ $t('i18n_7a3c815b1e') }}
+              <template #title> {{ $t('i18n_d0874922f0') }}</template>
+              <QuestionCircleOutlined/>
+            </a-tooltip>
+          </template>
+          <a-textarea
+            v-model:value="temp.fileDirs"
+            :auto-size="{ minRows: 3, maxRows: 5 }"
+            :placeholder="$t('i18n_baefd3db91')"
+          />
+        </a-form-item>
+
+        <a-form-item :label="$t('i18n_649231bdee')" name="suffix">
+          <a-textarea
+            v-model:value="temp.allowEditSuffix"
+            :rows="5"
+            style="resize: none"
+            :placeholder="$t('i18n_01081f7817')"
+          />
+        </a-form-item>
+        <!--            <a-form-item name="notAllowedCommand">
+                      <template #label>
+                        <a-tooltip>
+                          {{ $t('i18n_a39340ec59') }}
+                          <template #title>
+                            {{ $t('i18n_6bb5ba7438') }}
+                            <ul>
+                              <li>{{ $t('i18n_7114d41b1d') }}</li>
+                              <li>{{ $t('i18n_d8bf90b42b') }}</li>
+                            </ul>
+                          </template>
+                          <QuestionCircleOutlined/>
+                        </a-tooltip>
+                      </template>
+                      <a-textarea
+                        v-model:value="temp.notAllowedCommand"
+                        :auto-size="{ minRows: 3, maxRows: 5 }"
+                        :placeholder="$t('i18n_b6afcf9851')"
+                      />
+                    </a-form-item>-->
+      </a-form>
+    </CustomModal>
+    <!-- 分配到其他工作空间 -->
+    <CustomModal
+      v-if="syncToWorkspaceVisible"
+      v-model:open="syncToWorkspaceVisible"
+      destroy-on-close
+      :confirm-loading="confirmLoading"
+      :title="$t('i18n_ef8525efce')"
+      :mask-closable="false"
+      @ok="handleSyncToWorkspace"
+    >
+      <a-space direction="vertical" style="width: 100%">
+        <a-alert :message="$t('i18n_138a676635')" type="warning" show-icon>
+          <template #description>{{ $t('i18n_a63fe7b615') }}</template>
+        </a-alert>
+        <a-form :model="temp" :label-col="{ span: 6 }" :wrapper-col="{ span: 14 }">
+          <a-form-item :label="$t('i18n_b4a8c78284')" name="workspaceId">
+            <a-select
+              v-model:value="temp.workspaceId"
+              show-search
+              :filter-option="
+                    (input, option) => {
+                      const children = option.children && option.children()
+                      return (
+                        children &&
+                        children[0].children &&
+                        children[0].children.toLowerCase().indexOf(input.toLowerCase()) >= 0
+                      )
+                    }
+                  "
+              :placeholder="$t('i18n_b3bda9bf9e')"
+            >
+              <a-select-option v-for="item in workspaceList" :key="item.id">{{ item.name }}</a-select-option>
+            </a-select>
+          </a-form-item>
+        </a-form>
+      </a-space>
+    </CustomModal>
+
+  </div>
+</template>
+<script>
+import {
+  machineFtpListData,
+  machineFtpListGroup,
+  machineFtpEdit,
+  machineFtpDelete,
+  machineListGroupWorkspaceFtp,
+  machineFtpSaveWorkspaceConfig,
+  machineFtpDistribute,
+  restHideField,
+  importTemplate,
+  exportData,
+  importData,
+  statusMap,
+} from '@/api/system/assets-ftp'
+import {
+  COMPUTED_PAGINATION,
+  PAGE_DEFAULT_LIST_QUERY,
+  parseTime,
+  CHANGE_PAGE,
+  renderSize,
+  formatPercent,
+  formatDuration,
+  formatPercent2Number
+} from '@/utils/const'
+import fastInstall from '@/pages/node/fast-install.vue'
+import CustomSelect from '@/components/customSelect'
+import CustomInput from '@/components/customInput'
+import FtpFile from '@/pages/ftp/ftp-file'
+import {deleteForeFtp} from '@/api/ftp'
+import {getWorkspaceEnvAll, getWorkSpaceListAll} from '@/api/workspace'
+
+export default {
+  components: {
+    fastInstall,
+    CustomSelect,
+    FtpFile,
+    CustomInput
+  },
+  data() {
+    return {
+      loading: true,
+      groupList: [],
+      list: [],
+      listQuery: Object.assign({}, PAGE_DEFAULT_LIST_QUERY),
+      editFtpVisible: false,
+      temp: {},
+      statusMap,
+      // 传输模式: '',
+      options: [
+        {label: "主动模式", value: 'Active'},
+        {label: "被动模式", value: 'Passive'}
+      ],
+
+      columns: [
+        {
+          title: this.$t('i18n_d7ec2d3fea'),
+          dataIndex: 'name',
+          width: 120,
+          sorter: true,
+          ellipsis: true
+        },
+
+        {
+          title: 'Host',
+          dataIndex: 'host',
+          width: 120,
+          sorter: true,
+          ellipsis: true
+        },
+        {
+          title: this.$t('i18n_819767ada1'),
+          dataIndex: 'user',
+          sorter: true,
+          width: '80px',
+          ellipsis: true,
+          tooltip: true
+        },
+        {
+          title: "服务器语言",
+          dataIndex: 'serverLanguageCode',
+          width: 120,
+          sorter: true,
+          ellipsis: true
+        },
+        {
+          title: '服务器系统关键词',
+          dataIndex: 'systemKey',
+          sorter: true,
+          width: '100px',
+          ellipsis: true
+        },
+        {
+          title: "编码格式",
+          dataIndex: "charset",
+          sorter: true,
+          width: 120,
+          ellipsis: true,
+        },
+        {
+          title: this.$t('i18n_7912615699'),
+          dataIndex: 'status',
+          ellipsis: true,
+          align: 'center',
+          width: '100px'
+        },
+        {
+          title: this.$t('i18n_eca37cb072'),
+          dataIndex: 'createTimeMillis',
+          ellipsis: true,
+          sorter: true,
+          customRender: ({text}) => parseTime(text),
+          width: '170px'
+        },
+        {
+          title: this.$t('i18n_1303e638b5'),
+          dataIndex: 'modifyTimeMillis',
+          sorter: true,
+          ellipsis: true,
+          customRender: ({text}) => parseTime(text),
+          width: '170px'
+        },
+        {
+          title: this.$t('i18n_2b6bc0f293'),
+          dataIndex: 'operation',
+
+          width: '310px',
+          align: 'center',
+          // ellipsis: true,
+          fixed: 'right'
+        }
+      ],
+
+      // 表单校验规则
+      rules: {
+        name: [{required: true, message: this.$t('i18n_06e2f88f42'), trigger: 'blur'}],
+        host: [{required: true, message: this.$t('i18n_81485b76d8'), trigger: 'blur'}],
+        port: [{required: true, message: this.$t('i18n_8d0fa2ee2d'), trigger: 'blur'}],
+        connectType: [
+          {
+            required: true,
+            message: this.$t('i18n_4ed1662cae'),
+            trigger: 'blur'
+          }
+        ],
+
+        mode: [{required: true, message: this.$t('i18n_3103effdfd'), trigger: 'blur'}],
+        user: [{required: true, message: this.$t('i18n_3103effdfd'), trigger: 'blur'}],
+        password: [{required: true, message: this.$t('i18n_209f2b8e91'), trigger: 'blur'}]
+      },
+      nodeVisible: false,
+
+      terminalVisible: false,
+      terminalFullscreen: false,
+      viewOperationLog: false,
+      drawerVisible: false,
+      workspaceFtpList: [],
+      viewWorkspaceFtp: false,
+      configWorkspaceFtpVisible: false,
+      syncToWorkspaceVisible: false,
+      workspaceList: [],
+      tableSelections: [],
+      envVarList: [],
+      confirmLoading: false
+    }
+  },
+  computed: {
+    pagination() {
+      return COMPUTED_PAGINATION(this.listQuery)
+    },
+    rowSelection() {
+      return {
+        onChange: (selectedRowKeys) => {
+          this.tableSelections = selectedRowKeys
+        },
+        selectedRowKeys: this.tableSelections
+      }
+    },
+    activePage() {
+      return this.$attrs.routerUrl === this.$route.path
+    }
+  },
+  created() {
+    this.loadData()
+    this.loadGroupList()
+    this.getWorkEnvList()
+  },
+  methods: {
+    formatDuration,
+    renderSize,
+    formatPercent,
+    formatPercent2Number,
+    getWorkEnvList() {
+      getWorkspaceEnvAll({
+        workspaceId: 'GLOBAL'
+      }).then((res) => {
+        if (res.code === 200) {
+          this.envVarList = res.data
+        }
+      })
+    },
+    // 加载数据
+    loadData(pointerEvent) {
+      this.loading = true
+      this.listQuery.page = pointerEvent?.altKey || pointerEvent?.ctrlKey ? 1 : this.listQuery.page
+      machineFtpListData(this.listQuery).then((res) => {
+        if (res.code === 200) {
+          this.list = res.data.result
+          this.listQuery.total = res.data.total
+          //
+        }
+        this.loading = false
+      })
+    },
+    // 获取所有的分组
+    loadGroupList() {
+      machineFtpListGroup().then((res) => {
+        if (res.data) {
+          this.groupList = res.data
+        }
+      })
+    },
+    // 新增 FTP
+    handleAdd() {
+      this.temp = {
+        charset: 'UTF-8',
+        port: 21,
+        timeout: 5,
+        mode: 'Active'
+      }
+      this.editFtpVisible = true
+      // @author jzy 08-04
+      this.$refs['editFtpForm'] && this.$refs['editFtpForm'].resetFields()
+    },
+    // 修改
+    handleEdit(record) {
+      this.temp = Object.assign({}, record, {
+        allowEditSuffix: record.allowEditSuffix ? JSON.parse(record.allowEditSuffix).join('\r\n') : ''
+      })
+
+      this.temp = {
+        ...this.temp,
+
+        timeout: record.timeout || 5
+      }
+      this.editFtpVisible = true
+      // @author jzy 08-04
+      this.$refs['editFtpForm'] && this.$refs['editFtpForm'].resetFields()
+      this.loadGroupList()
+    },
+    // 提交 FTP 数据
+    handleEditFtpOk() {
+      // 检验表单
+      this.$refs['editFtpForm'].validate().then(() => {
+        // 提交数据
+        this.confirmLoading = true
+        machineFtpEdit(this.temp)
+          .then((res) => {
+            if (res.code === 200) {
+              $notification.success({
+                message: res.msg
+              })
+              this.editFtpVisible = false
+              this.loadData()
+              this.loadGroupList()
+            }
+          })
+          .finally(() => {
+            this.confirmLoading = false
+          })
+      })
+    },
+    // 分页、排序、筛选变化时触发
+    changePage(pagination, filters, sorter) {
+      this.listQuery = CHANGE_PAGE(this.listQuery, {pagination, sorter})
+      this.loadData()
+    },
+    // 安装节点
+    install() {
+      this.nodeVisible = true
+    },
+
+    // 删除
+    handleDelete(record) {
+      $confirm({
+        title: this.$t('i18n_c4535759ee'),
+        content: this.$t('i18n_0aa639865c'),
+        zIndex: 1009,
+        okText: this.$t('i18n_e83a256e4f'),
+        cancelText: this.$t('i18n_625fb26b4b'),
+        onOk: () => {
+          return machineFtpDelete({
+            id: record.id
+          }).then((res) => {
+            if (res.code === 200) {
+              $notification.success({
+                message: res.msg
+              })
+              this.loadData()
+            }
+          })
+        }
+      })
+    },
+
+    // 查看工作空间的 ftp
+    handleViewWorkspaceFtp(item) {
+      machineListGroupWorkspaceFtp({
+        id: item.id
+      }).then((res) => {
+        if (res.code === 200) {
+          this.temp = {
+            machineFtpId: item.id
+          }
+          this.viewWorkspaceFtp = true
+          this.workspaceFtpList = res.data
+        }
+      })
+    },
+    // 配置 ftp
+    configWorkspaceFtp(item) {
+      this.temp = {
+        ...this.temp,
+        id: item.id,
+        name: item.name,
+        fileDirs: item.fileDirs ? JSON.parse(item.fileDirs).join('\r\n') : '',
+        allowEditSuffix: item.allowEditSuffix ? JSON.parse(item.allowEditSuffix).join('\r\n') : '',
+        workspaceName: item.workspace?.name,
+        notAllowedCommand: item.notAllowedCommand
+      }
+      this.configWorkspaceFtpVisible = true
+    },
+    // 提交 FTP 配置 数据
+    handleConfigWorkspaceFtpOk() {
+      // 检验表单
+      this.$refs['editConfigWorkspaceFtpForm'].validate().then(() => {
+        this.confirmLoading = true
+        // 提交数据
+        machineFtpSaveWorkspaceConfig(this.temp)
+          .then((res) => {
+            if (res.code === 200) {
+              $notification.success({
+                message: res.msg
+              })
+              this.configWorkspaceFtpVisible = false
+              machineListGroupWorkspaceFtp({
+                id: this.temp.machineFtpId
+              }).then((res) => {
+                if (res.code === 200) {
+                  this.workspaceFtpList = res.data
+                }
+              })
+            }
+          })
+          .finally(() => {
+            this.confirmLoading = false
+          })
+      })
+    },
+    // 删除工作空间的数据
+    handleDeleteWorkspaceItem(record) {
+      $confirm({
+        title: this.$t('i18n_c4535759ee'),
+        zIndex: 1009,
+        content: this.$t('i18n_2ff65378a4'),
+        okText: this.$t('i18n_e83a256e4f'),
+        cancelText: this.$t('i18n_625fb26b4b'),
+        onOk: async () => {
+          const {code, msg} = await deleteForeFtp(record.id)
+          if (code === 200) {
+            $notification.success({
+              message: msg
+            })
+            const res = await machineListGroupWorkspaceFtp({
+              id: this.temp.machineFtpId
+            })
+            if (res.code === 200) {
+              this.workspaceFtpList = res.data
+            }
+          }
+        }
+      })
+    },
+    // 文件管理
+    handleFile(record) {
+      this.temp = Object.assign({}, record)
+
+      this.drawerVisible = true
+    },
+    // 加载工作空间数据
+    loadWorkSpaceListAll() {
+      getWorkSpaceListAll().then((res) => {
+        if (res.code === 200) {
+          this.workspaceList = res.data
+        }
+      })
+    },
+    // 同步到其他工作情况
+    syncToWorkspaceShow(item) {
+      this.syncToWorkspaceVisible = true
+      this.loadWorkSpaceListAll()
+      if (item) {
+        this.temp = {
+          ids: item.id
+        }
+      }
+    },
+    handleSyncToWorkspace() {
+      if (!this.temp.workspaceId) {
+        $notification.warn({
+          message: this.$t('i18n_b3bda9bf9e')
+        })
+        return false
+      }
+      if (!this.temp.ids) {
+        this.temp = {...this.temp, ids: this.tableSelections.join(',')}
+        this.tableSelections = []
+      }
+      this.confirmLoading = true
+      // 同步
+      machineFtpDistribute(this.temp)
+        .then((res) => {
+          if (res.code == 200) {
+            $notification.success({
+              message: res.msg
+            })
+
+            this.syncToWorkspaceVisible = false
+            return false
+          }
+        })
+        .finally(() => {
+          this.confirmLoading = false
+        })
+    },
+    // 清除隐藏字段
+    handerRestHideField(record) {
+      $confirm({
+        title: this.$t('i18n_c4535759ee'),
+        zIndex: 1009,
+        content: "真的要清除 FTP 隐藏字段信息么?(密码)",
+        okText: this.$t('i18n_e83a256e4f'),
+        cancelText: this.$t('i18n_625fb26b4b'),
+        onOk: () => {
+          return restHideField(record.id).then((res) => {
+            if (res.code === 200) {
+              $notification.success({
+                message: res.msg
+              })
+              this.loadData()
+            }
+          })
+        }
+      })
+    },
+    // 下载导入模板
+    handlerImportTemplate() {
+      window.open(importTemplate(), '_blank')
+    },
+    handlerExportData() {
+      window.open(exportData({...this.listQuery}), '_blank')
+    },
+    beforeUpload(file) {
+      const formData = new FormData()
+      formData.append('file', file)
+      importData(formData).then((res) => {
+        if (res.code === 200) {
+          $notification.success({
+            message: res.msg
+          })
+          this.loadData()
+        }
+      })
+    }
+  }
+}
+</script>

+ 10 - 0
web-vue/src/router/index.ts

@@ -73,6 +73,11 @@ const children = [
     name: 'node-command-log',
     component: () => import('../pages/ssh/command-log.vue')
   },
+  {
+    path: '/ftp',
+    name: 'node-ftp',
+    component: () => import('../pages/ftp/ftp.vue')
+  },
   {
     path: '/dispatch/list',
     name: 'dispatch-list',
@@ -177,6 +182,11 @@ const management = [
     name: 'system-machine-ssh-list',
     component: () => import('../pages/system/assets/ssh/ssh-list.vue')
   },
+  {
+    path: '/system/assets/ftp-list',
+    name: 'system-machine-ftp-list',
+    component: () => import('../pages/system/assets/ftp/ftp-list.vue')
+  },
   {
     path: '/system/assets/docker-list',
     name: 'system-machine-docker-list',

+ 2 - 0
web-vue/src/router/route-menu.ts

@@ -19,6 +19,7 @@ const routeMenuMap: Record<string, string> = {
   dockerList: '/docker/list',
   dockerSwarm: '/docker/swarm',
   sshList: '/ssh',
+  ftpList: '/ftp',
   commandList: '/ssh/command',
   commandLogList: '/ssh/command-log',
   outgivingList: '/dispatch/list',
@@ -55,6 +56,7 @@ const routeMenuMap: Record<string, string> = {
   globalEnv: '/system/global-env',
   machine_node_info: '/system/assets/machine-list',
   machine_ssh_info: '/system/assets/ssh-list',
+  machine_ftp_info: '/system/assets/ftp-list',
   machine_docker_info: '/system/assets/docker-list',
   global_repository: '/system/assets/repository-list',
   script_library: '/system/assets/script-library',