13 Commits 3fe63c1db2 ... 36d1358aa2

Tác giả SHA1 Thông báo Ngày
  bwcx_jzy 36d1358aa2 Merge branch 'refs/heads/dev-ftp1' into dev 3 tháng trước cách đây
  bwcx_jzy cd01f8c9d1 fix(web-vue): 修复表格自定义列在部分字段不生效问题 3 tháng trước cách đây
  wxyshine f5e69e07d0 feat(server): 国际化替换: 新增构建后发布到ftp 3 tháng trước cách đây
  wxyshine ce3200b1db feat(server): 新增构建后发布到ftp 3 tháng trước cách đây
  bwcx_jzy 2e8a9e8c31 feat(server): 优化脚本日志和 SSH 命令日志支持批量删除 4 tháng trước cách đây
  bwcx_jzy d053fa5449 fix(server): 修复终端输入命令时按 Backspace 会退出终端的问题 4 tháng trước cách đây
  wxyshine 8abd4d42b0 feat(server): 国际化替换:完善功能管理FTP列表功能,系统管理FTP列表功能 4 tháng trước cách đây
  bwcx_jzy bda44b28b3 refactor(server): 抽离控制台按键事件处理逻辑 4 tháng trước cách đây
  wxyshine 9a709923cd feat(server): 完善功能管理FTP列表功能,系统管理FTP列表功能 4 tháng trước cách đây
  bwcx_jzy 9ed2761191 feat(assets): 添加 FTP资产管理功能 7 tháng trước cách đây
  bwcx_jzy a2ffedee39 Merge remote-tracking branch 'origin/dev' into dev 9 tháng trước cách đây
  bwcx_jzy bfc7f2bcb5 feat(server): 新增 FTP资产管理功能 9 tháng trước cách đây
  bwcx_jzy 678064a8fe refactor(server): 优化数据库操作日志信息 9 tháng trước cách đây
55 tập tin đã thay đổi với 7817 bổ sung298 xóa
  1. 3 0
      CHANGELOG-BETA.md
  2. 47 2
      modules/common/src/main/resources/i18n/messages_en_US.properties
  3. 51 6
      modules/common/src/main/resources/i18n/messages_zh_CN.properties
  4. 47 2
      modules/common/src/main/resources/i18n/messages_zh_HK.properties
  5. 47 2
      modules/common/src/main/resources/i18n/messages_zh_TW.properties
  6. 45 0
      modules/common/src/main/resources/i18n/words.json
  7. 4 5
      modules/common/src/test/resources/baidubce_translate.txt
  8. 6 0
      modules/server/pom.xml
  9. 4 0
      modules/server/src/main/java/org/dromara/jpom/build/BuildExtraModule.java
  10. 81 11
      modules/server/src/main/java/org/dromara/jpom/build/ReleaseManage.java
  11. 29 0
      modules/server/src/main/java/org/dromara/jpom/configuration/AssetsConfig.java
  12. 44 1
      modules/server/src/main/java/org/dromara/jpom/controller/build/BuildInfoController.java
  13. 211 0
      modules/server/src/main/java/org/dromara/jpom/controller/ftp/FtpController.java
  14. 66 0
      modules/server/src/main/java/org/dromara/jpom/controller/ftp/FtpFileController.java
  15. 34 9
      modules/server/src/main/java/org/dromara/jpom/controller/script/ScriptLogController.java
  16. 12 9
      modules/server/src/main/java/org/dromara/jpom/controller/ssh/CommandLogController.java
  17. 635 0
      modules/server/src/main/java/org/dromara/jpom/func/assets/controller/BaseFtpFileController.java
  18. 457 0
      modules/server/src/main/java/org/dromara/jpom/func/assets/controller/MachineFtpController.java
  19. 75 0
      modules/server/src/main/java/org/dromara/jpom/func/assets/controller/MachineFtpFileController.java
  20. 1 1
      modules/server/src/main/java/org/dromara/jpom/func/assets/controller/MachineSshController.java
  21. 115 0
      modules/server/src/main/java/org/dromara/jpom/func/assets/model/MachineFtpModel.java
  22. 245 0
      modules/server/src/main/java/org/dromara/jpom/func/assets/server/MachineFtpServer.java
  23. 125 0
      modules/server/src/main/java/org/dromara/jpom/model/data/FtpModel.java
  24. 1 0
      modules/server/src/main/java/org/dromara/jpom/model/enums/BuildReleaseMethod.java
  25. 15 3
      modules/server/src/main/java/org/dromara/jpom/permission/ClassFeature.java
  26. 255 0
      modules/server/src/main/java/org/dromara/jpom/service/node/ftp/FtpService.java
  27. 46 0
      modules/server/src/main/java/org/dromara/jpom/socket/handler/KeyControl.java
  28. 202 0
      modules/server/src/main/java/org/dromara/jpom/socket/handler/KeyEventCycle.java
  29. 5 231
      modules/server/src/main/java/org/dromara/jpom/socket/handler/SshHandler.java
  30. 8 1
      modules/server/src/main/resources/application.yml
  31. 7 0
      modules/server/src/main/resources/config_default/application.yml
  32. 11 0
      modules/server/src/main/resources/menus/zh-CN/index.json
  33. 5 0
      modules/server/src/main/resources/menus/zh-CN/system.json
  34. 2 0
      modules/server/src/main/resources/sql-view/table.all.v1.1.csv
  35. 39 0
      modules/server/src/main/resources/sql-view/table.all.v1.2.csv
  36. 725 0
      modules/sub-plugin/ssh-jsch/src/test/java/SSHCommandRecorder.java
  37. 444 0
      modules/sub-plugin/ssh-jsch/src/test/java/TerminalParserRecorder.java
  38. 2 1
      web-vue/src/api/build-info.ts
  39. 227 0
      web-vue/src/api/ftp-file.ts
  40. 76 0
      web-vue/src/api/ftp.ts
  41. 9 0
      web-vue/src/api/server-script.ts
  42. 123 0
      web-vue/src/api/system/assets-ftp.ts
  43. 2 2
      web-vue/src/components/customTable/index.vue
  44. 29 0
      web-vue/src/i18n/locales/en_us.json
  45. 29 0
      web-vue/src/i18n/locales/zh_cn.json
  46. 29 0
      web-vue/src/i18n/locales/zh_hk.json
  47. 29 0
      web-vue/src/i18n/locales/zh_tw.json
  48. 136 2
      web-vue/src/pages/build/edit.vue
  49. 1281 0
      web-vue/src/pages/ftp/ftp-file.vue
  50. 603 0
      web-vue/src/pages/ftp/ftp.vue
  51. 68 6
      web-vue/src/pages/script/script-log.vue
  52. 61 4
      web-vue/src/pages/ssh/command-log.vue
  53. 952 0
      web-vue/src/pages/system/assets/ftp/ftp-list.vue
  54. 10 0
      web-vue/src/router/index.ts
  55. 2 0
      web-vue/src/router/route-menu.ts

+ 3 - 0
CHANGELOG-BETA.md

@@ -9,6 +9,9 @@
 ### 🐞 解决BUG、优化功能
 
 1. 【server】优化 数据库表支持配置前缀 `jpom.db.table-prefix` (感谢@ccx2480)
+2. 【server】修复 终端输入命令,按Backspace 会退出终端(感谢[@dgs](https://gitee.com/dgs0924) [Gitee issues ICA57K](https://gitee.com/dromara/Jpom/issues/ICA57K) )
+3. 【server】优化 脚本日志和 SSH 命令日志支持批量删除(感谢[@lin_yeqi](https://gitee.com/lin_yeqi) [Gitee issues IBIM6W](https://gitee.com/dromara/Jpom/issues/IBIM6W) )
+4. 【server】修复 表格自定义列在部分字段不生效情况
 
 ------
 

+ 47 - 2
modules/common/src/main/resources/i18n/messages_en_US.properties

@@ -1,5 +1,5 @@
 #i18n en_US
-#Thu Jan 09 16:59:46 CST 2025
+#Thu Jun 12 16:41:19 CST 2025
 i18n.ssh_info_does_not_exist.5ed0=SSH information does not exist
 i18n.incompatible_program_versions.5291=The current program version {} The new version of the program is minimum compatible {} and cannot be upgraded directly
 i18n.no_projects_configured.e873=No items are configured
@@ -8,6 +8,7 @@ i18n.machine_ssh_info.8dbb=Machine SSH information
 i18n.machine_info_not_exist.3468=The corresponding machine information does not exist.
 i18n.unsupported_method.a1de=Unsupported way
 i18n.delete_local_image_failed.91fa=Failed to delete local image
+i18n.cluster_not_grouped.8f54=The current cluster has not bound packets and cannot monitor FTP asset information
 i18n.service_name_in_cluster_required.5446=Please fill in the service name in the cluster
 i18n.cluster_manager_node_not_found.1cd0=No cluster management node found
 i18n.distribute_id_already_exists.2168=The distribution id already exists
@@ -34,6 +35,7 @@ i18n.start_waiting_for_data_migration.e76f=Start waiting for data migration
 i18n.empty_file_or_folder_for_publish.cae8=The published file or folder is empty and cannot continue publishing
 i18n.demo_account_cannot_use_feature.a1a1=The demo account cannot use this function.
 i18n.repository_type_required.9414=Please select a warehouse type
+i18n.start_backup_database.e554=Start backing up the database
 i18n.async_resource_expired.2ddc=The asynchronous resource has expired and needs to be actively closed, {} {}
 i18n.mark_cannot_be_empty.1927=Tag cannot be empty
 i18n.get_success.fb55=Get success
@@ -50,9 +52,11 @@ i18n.upload_failed_no_matching_project.b219=Upload failed, no corresponding dist
 i18n.no_branches_or_tags_in_repository.76b6=The warehouse does not have any branches or labels.
 i18n.download_file_description.10cb=Download file {} {} {}
 i18n.delay_build.7d62=Start building after a delay of {} seconds
+i18n.ftp_not_exist.f9b3=There is no corresponding ftp.
 i18n.default_cluster.38cf=default cluster
 i18n.node_and_check_project_failed.ac4b=Node and check item failed
 i18n.load_oauth2_config.da42=Load oauth2 configuration :{} {}
+i18n.affected_rows.5781=Number of rows affected
 i18n.cluster_not_bound_to_group_for_node_monitoring.1586=The current cluster has not been bound to a group, so the asset information of the cluster nodes cannot be monitored
 i18n.cannot_execute_error.4c29=Cannot execute\: error
 i18n.no_environment_variable.c79f=No corresponding environment variables
@@ -114,6 +118,7 @@ i18n.no_ssh_entry_found.d0e1=No corresponding ssh entry found\: {}
 i18n.build_runs_on_image_interrupted.00fd=Building the runsOn image was interrupted
 i18n.incorrect_parameter_format.9efb=The format of the passed parameter is incorrect
 i18n.multiple_certificate_files_found.bee3=Found more than 2 certificate files
+i18n.file_not_exist_or_unable_to_download.b977=The file does not exist or cannot be downloaded.
 i18n.invalid_runs_on_image_name.4b96=runsOn image name is invalid
 i18n.dockerfile_path_required.69ac=Please fill in the path to the Dockerfile to be executed
 i18n.file_upload_mode_not_configured.b3b2=No profile upload mode
@@ -248,6 +253,7 @@ i18n.select_node_to_modify.6617=Please select the section to modify
 i18n.correct_dingtalk_address_required.2b4a=Please enter the correct DingTalk address
 i18n.query_folder_sftp_failed.9d35=Failed to query folder SFTP,
 i18n.name_field_required.e0c5=Line {} name field cannot be empty
+i18n.ftp_connection_failed_message.bd99=Connection to FTP failed\:
 i18n.plugin_end_log_connection_successful.9035=Connection successful\: plugin-side log
 i18n.start_building_with_number_and_path.c41c=Start building \#{} Build execution path\: {}
 i18n.start_executing_upload_pre_command.fb5c=Start executing the pre-upload command
@@ -292,6 +298,7 @@ i18n.login_name_cannot_contain_chinese_and_special_characters.48a8=Login name ca
 i18n.cannot_join_cluster_as_role.01d4=Cannot join cluster as {} role
 i18n.not_an_enumeration.8244=Not an enumeration
 i18n.script_not_exist.b180=The corresponding script no longer exists
+i18n.file_not_exist_or_unable_to_open.b045=The file does not exist or cannot be opened.
 i18n.already_offline.d3b5=It's offline.
 i18n.rebuild_success.5938=Rebuild successfully
 i18n.hours.2de0=hour
@@ -328,6 +335,7 @@ i18n.upload_progress_template.ac3f=Upload file progress \:{}/{} {}
 i18n.mark_already_exists.0ccc=tag already exists
 i18n.docker_not_found.2a2e=\ No docker found. Maybe the docker tag is filled in incorrectly, you need to configure the tag for docker.
 i18n.download_remote_file_failed.fcc3=Failed to download remote file\:
+i18n.no_matching_asset_ftp.d420=There is no corresponding asset FTP.
 i18n.no_file_found.6f1b=No {} file found
 i18n.distribute_result.a230=Distribution result\: {}
 i18n.multiple_ssh_addresses_found.b3f7=SSH address {} has multiple data, and configuration information using {} SSH will be automatically merged
@@ -335,6 +343,7 @@ i18n.no_management_permission.fd25=You do not have the corresponding administrat
 i18n.workspace_env_vars.f7e8=Workspace environment variables
 i18n.unsupported_mode.a3d3=Unsupported modes\:
 i18n.select_node_and_project.6021=Please select a node and project
+i18n.initialize_sql.6691=Execute the initialization SQL file
 i18n.operation_succeeded.3313=Operation successful
 i18n.url_length_exceeded.ca1c=The URL length cannot exceed 200.
 i18n.waiting_to_close_process.3634=Waiting to close the [Process] process\: {}
@@ -364,6 +373,7 @@ i18n.webhooks_invocation_error.9792=WebHooks call error
 i18n.missing_parent_id.4331=Missing Parent Task ID
 i18n.data_modification_time_format_incorrect.7ffe=The data modification time format is incorrect {} {}
 i18n.cannot_delete_recent_logs.ee19=Logs related to the past day cannot be deleted (file modification time)
+i18n.monitor_name.9aff=monitor
 i18n.agent_jar_not_exist.28ac=Agent JAR package does not exist
 i18n.parse_certificate_unknown_error.c43c=An unknown error occurred in parsing the certificate.
 i18n.node_info_incomplete.3b69=The corresponding node information is incomplete and cannot continue.
@@ -377,6 +387,7 @@ i18n.system_logs.84aa=system log
 i18n.machines_ssh_data_fixed.1387=Successfully repaired {} machine SSH data
 i18n.docker_label_required.b690=Please fill in the docker tag to execute
 i18n.no_matching_permission.09cf=Insufficient permissions not matched
+i18n.ftp_create_folder_exception.a4fe=FTP folder creation exception
 i18n.data_id_does_not_exist.a566=Data ID does not exist
 i18n.no_ssh_info.a8ec=No corresponding SSH information
 i18n.distribution_with_build_items_message.45f5=There are construction items in the current distribution and cannot be deleted directly (you need to unbind or delete the associated data in advance to delete it)
@@ -401,6 +412,7 @@ i18n.node_not_enabled.10ef={} Node is not enabled
 i18n.client_id_not_configured.ab8e=No clientId configured
 i18n.build_product_dir_not_empty.ba06=The bundle directory cannot be empty, length 1-200
 i18n.trigger_token_error_or_expired.8976=Triggered token error, or has expired
+i18n.ftp_connection_failed.1f2f=FTP connection failed
 i18n.auth_info_error.c184=Authorization information error
 i18n.download_file_error.5bcd=Abnormal download file\:
 i18n.rollback_ended.fb1d=End of execution rollback\: {}
@@ -416,6 +428,7 @@ i18n.id_already_exists.6208=ID already exists
 i18n.ssh_info.ebe6=SSH information
 i18n.current_status.81c0=\ Currently still available\:
 i18n.project_path_no_spaces.263c=The project path cannot contain spaces
+i18n.ftp_asset_management.c6a5=FTP Asset Management
 i18n.please_fill_in_address_of.9e02=Please fill in the address of% s
 i18n.rsa_private_key_file_invalid.5f12=The RSA private key file does not exist or is incorrect
 i18n.ssh_node_required.4566=Please select the ssh node
@@ -456,6 +469,7 @@ i18n.load_plugin.1f64=Load\: {} plugin
 i18n.host_field_required.5c36=Line {} The host field cannot be empty
 i18n.container_build_interrupted.a17b=Container build was interrupted\:
 i18n.upload_action.d5a7=upload
+i18n.ftp_connection_or_operation_exception.09af=FTP connection or operation is abnormal
 i18n.delete_file_failure.041f=Failed to delete file, please check
 i18n.no_cluster_info_found.fb40=The corresponding cluster information was not found.
 i18n.script_template_log.30cb=Script template log
@@ -509,6 +523,7 @@ i18n.invalid_zip_file.3092=The compressed package uploaded is not a Jpom [{}] pa
 i18n.publish_command_non_zero_exit_code.ea80=Execute the publish command Exit code is not 0, {}
 i18n.project_path_promotion_issue.2250=There is an issue with the promoted directory in the project path
 i18n.handle_node_deletion_script_failure_duplicate.821e=Failed to process {} node deletion script {}
+i18n.ftp_folder_query_failed.0011=Unable to query folder FTP,
 i18n.no_matching_process_type.b468=Did not match the appropriate processing type
 i18n.unsupported_type.7495=Unsupported types
 i18n.submit_task_queue_success.5f5b=Submit task queue successfully, current queue number\:
@@ -567,6 +582,7 @@ i18n.select_correct_pre_publish_script.d230=Please select the correct pre-releas
 i18n.greeting.5ecd=Hello, Jpom.
 i18n.ssh_connection_failed.4719=SSH connection failed
 i18n.oauth2_redirect_failed.6dcd=Failed to jump to oauth2, {} {}
+i18n.ftp_upload_failed.8298=FTP upload failed
 i18n.data_workspace_mismatch.ae1d=The data workspace and the operation workspace are inconsistent
 i18n.process_file_event_exception.e8e6=Handling file event exceptions
 i18n.current_docker_cluster_has_no_management_nodes_online.56cd=The current {} docker cluster has no management nodes online
@@ -578,6 +594,7 @@ i18n.restart_operation.5e3a=Perform a restart operation
 i18n.no_h2_data_info_for_migration.5799=No h2 data information does not need to be migrated
 i18n.publish_success.2fff=Published successfully
 i18n.system_cache.c4a8=system cache
+i18n.no_ftp_item.8e39=No corresponding ftp entry
 i18n.distribution_machine_required.5921=Please select the machine to distribute
 i18n.build_call_container_exception.6e04=build call container exception
 i18n.process_killed_successfully.a4c3=Successful kill
@@ -586,6 +603,7 @@ i18n.auto_clear_data_errors.112f=Automatically clear data errors {} {}
 i18n.publish_directory_is_empty.79c6=Publish directory is empty
 i18n.file_or_directory_not_found.f03e=File does not exist or is a directory\:
 i18n.clear_file_cache_failed.5cd1=Failed to clear file cache
+i18n.database_backup_complete_path.861b=The database backup is complete, and the save path is
 i18n.file_downloading_status.c995=File downloading\:
 i18n.system_IP_authorization.9c08=System configuration IP authorization
 i18n.node_failed.20d5=Node failed\:
@@ -613,6 +631,7 @@ i18n.delete_failure_with_colon_and_full_stop.bc42=Delete failed\:
 i18n.product_directory_cannot_skip_levels.3ad4=The product catalog cannot be upgraded\:
 i18n.fix_null_workspace_data.4d0b=Fix data {} {} with null workspace
 i18n.external_config_file_path.06ec=external profile path
+i18n.asset_ftp_info.3b75=Asset FTP information
 i18n.soft_link_project_department_exists.fa97=The project department of Soft Chain exists
 i18n.docker_info.00d2=Docker information
 i18n.log_file_does_not_exist.f6c6=Log file does not exist
@@ -621,6 +640,7 @@ i18n.no_file_info.db01=There is no corresponding file information.
 i18n.record_operation_log_exception.8012=Log operation log exception
 i18n.corresponding_file_required.57b3=Please select the corresponding file
 i18n.build_command_no_delete.df52=Build commands cannot contain delete commands
+i18n.ftp_info_table.b177=FTP information table
 i18n.file_merge_error.f32f=Abnormal after file merging, incomplete files may be damaged.
 i18n.script_info_not_found.bd8d=No corresponding script information found.
 i18n.cannot_modify_own_info.4036=You can't modify your information.
@@ -675,6 +695,7 @@ i18n.container_build_host_config_conversion_failure.27aa=Container build hostCon
 i18n.build_task_count_and_queue_count.f0b6=Number of tasks currently under construction\: {}, Number of tasks in queue\: {} {}
 i18n.node_cache.d68c=Node cache
 i18n.cluster_node_not_in_system.0645=The node corresponding to the current cluster cannot exit the cluster if it is not in this system.
+i18n.ftp_selection.c903=Please choose to distribute FTP items
 i18n.file_search_failed.231b=File search failed
 i18n.exported_ssh_data.ce88=Exported ssh data
 i18n.execution_ended_with_duration.a59b=Execute the end {} process, time consuming\: {}
@@ -703,6 +724,7 @@ i18n.ssh_batch_command_execution_exception.029a=SSH batch execution command exce
 i18n.content_type_not_supported.81a9=unsupported contentType
 i18n.cloud_server_network_issues.a865=Troubleshooting and positioning of network-related issues such as security group configuration of Cloud as a Service.
 i18n.start_building_image.eacd={} Start building the mirror {} {}
+i18n.ftp_already_exists.d66b=The corresponding FTP already exists
 i18n.configure_correct_redirect_url.058e=Please configure the correct redirect URL.
 i18n.no_cache_info_with_minus_one.52f2=No corresponding cache information\: -1
 i18n.no_node_entry_found.b1ef=No corresponding node item found\: {}
@@ -714,6 +736,7 @@ i18n.no_docker_info_found.6d38=No corresponding docker information was found.
 i18n.update_docker_machine_id_failed.063d=Updating DOCKER table machine id failed\:
 i18n.build_status_abnormal.8ca1=The build status is abnormal or cancelled
 i18n.node_connection_failure.896d=Node connection failed, please check if the node is online.
+i18n.database_load_success_url.5f64=The database loaded successfully and the URL is
 i18n.login_name_already_exists.2511=Login name already exists
 i18n.port_error.312e=port error
 i18n.account_disabled.9361=The account has been disabled and cannot be used.
@@ -763,9 +786,12 @@ i18n.error_sql.15ff=Error sql\: {}
 i18n.verification_method_not_configured.7358={} Authentication method not configured\: {}
 i18n.local_git_certificate_not_supported.b395=Local git-specified certificate pull code is temporarily not supported
 i18n.unsupported_method_with_colon.eae8=Unsupported methods\:
+i18n.exported_ftp_data.2b54=Exported ftp data
 i18n.current_docker_has_no_cluster_info.0b52=The current docker has no cluster information.
 i18n.cannot_delete_self.fec9=Can't delete yourself
 i18n.operation_monitoring_error.8036=execution action monitoring error
+i18n.ftp_read_file_failed.e738=FTP read file failed
+i18n.file_exists.145b=The folder or file already exists
 i18n.alias_or_token_error.d5c6=The alias or token is wrong, or has expired
 i18n.version_config_info.7b29=version configuration information
 i18n.delete_old_package.ca95=Delete the old package\: {}
@@ -778,6 +804,7 @@ i18n.node_service_stopped_failed_restart.4307=The [{}] project {} of the [{}] no
 i18n.associated_workspace.885b=Affiliated workspace
 i18n.auto_clear_machine_node_stats_logs.5279=Automatically clean up {} machine node statistical logs
 i18n.unsupported_mode.501d=Unsupported mode
+i18n.ftp_client_build_failure.aa55=Failed to build FTP client side [{}]\: {}
 i18n.missing_script_message.af89=No corresponding script found
 i18n.start_migrating.20d6=Start migration {} {}
 i18n.incompatible_database_version.8f7b=Database versions are not compatible, and cross-version upgrades need to be handled.
@@ -802,6 +829,7 @@ i18n.configure_user_notification.250d=Please configure user notifications
 i18n.operation_status_code.8231=operation status code
 i18n.agent_jar_damaged.74a8=Agent JAR damaged, please re-upload,
 i18n.read_error.7fa5=Read error
+i18n.import_success.ef46=Import successful\: {}
 i18n.network_resource_monitoring_error.4ede=Network interface card resource monitoring abnormality\:
 i18n.select_workspace_to_modify.ac87=Please select the workspace you want to modify
 i18n.decode_failure.822e=Decoding failed
@@ -832,6 +860,8 @@ i18n.project_id_not_found.b87e=No project id.
 i18n.operation_type.de9c=operation type
 i18n.project_has_logs_cannot_migrate.2e0e=The current project has log reading and cannot be migrated directly.
 i18n.delete_failure_with_colon.b429=Delete failed\:
+i18n.no_ftp_server_correspondence.1af7=There is no corresponding Ftp.
+i18n.ftp_import_template.8fa3=FTP import template
 i18n.tag_cannot_contain_colon.f9ae=Labels cannot contain\:
 i18n.distribute_id_already_exists_globally.6478=The distribution id already exists, and the distribution id needs to be globally unique.
 i18n.import_exception.04b6=Import the {} data exception\: {}
@@ -895,7 +925,9 @@ i18n.no_manager_node_found.5934=No management nodes were found in the current cl
 i18n.file_transfer_exception.bda6=Forwarded file exception
 i18n.select_node.f8a6=Please select a node
 i18n.upload_progress_with_units.44ad=Upload file progress \:{} {}/{} {}
+i18n.ftp_download_file_failed.2e42=FTP download file failed
 i18n.ssh_terminal_execution_log.58f1=SSH end point execution log
+i18n.ftp_file_manager.c52e=FTP file management
 i18n.project_monitor.d2ff=project monitoring
 i18n.push_image_interrupted.6377=Push image is interrupted\:
 i18n.default_value.1aa9={} [Default]
@@ -986,6 +1018,7 @@ i18n.certificate_already_exists.adf9=The current certificate already exists (in
 i18n.oshi_file_system_monitoring_exception.dc24=Oshi file system resource monitoring exception
 i18n.image_cannot_be_empty.1600=Mirror image cannot be empty
 i18n.no_corresponding_node_info.cd24=There is no corresponding node information.
+i18n.auto_backup_path.a16b=Automatically backup data files to the path
 i18n.node_plugin_version_required.2318=Node plugin version cannot be empty
 i18n.start_new_thread_for_h2_database_backup.9337=Start a new thread to perform an H2 database backup
 i18n.docker_exec_terminal_process_ended.c734=[{}] docker exec end point
@@ -1005,6 +1038,7 @@ i18n.protocol_not_supported.b906=Unsupported protocols
 i18n.force_unbind_succeeded.5bfd=Forced unbinding successful
 i18n.unexpected_exception.2b52=Abnormality occurs
 i18n.check_email_error.636c=Check email information error\: {}
+i18n.ftp_unauthorized_directory.df73=This ftp is not authorized to operate on this directory
 i18n.initialization_success.4725=Initialization successful
 i18n.running_status.d679=Running
 i18n.auto_clean_temp_dir.11d2=Automatic cleaning of temporary directories
@@ -1101,6 +1135,7 @@ i18n.batch_trigger_script_exception.8fb4=Server level script batch trigger excep
 i18n.upgrade_failure.4ae2=Upgrade failed
 i18n.no_branch_or_tag_message.8ae3=No {} branches/tags
 i18n.general_error_message.728a=Ah, something seems to be wrong, please try again later~
+i18n.ftp_connection_failure.0f31=Failed to close FTP connection
 i18n.service_info_incomplete_with_code2.e9ca=The service information is incomplete and cannot be operated\: -2
 i18n.ignored_operation.edee=Ignored operation\: {}
 i18n.monitor_name_cannot_be_empty.514a=Monitor name cannot be empty
@@ -1115,6 +1150,7 @@ i18n.select_related_data_id.7fba=Please select the associated data ID.
 i18n.node_name_required.5bdf=Please fill in the node name
 i18n.client_terminated_connection.6886=Client side terminates connection\: {}
 i18n.close_thread_pool.4cd9=Close the {} thread pool
+i18n.ftp_upload_file_exception.118c=Abnormal FTP upload file
 i18n.clear_ip_whitelist_config_success.8cf6=Clear IP whitelist Configuration successful
 i18n.no_corresponding_folder.621f=No corresponding folder
 i18n.no_online_manager_node_found.05d7=The current cluster does not find an online management node
@@ -1124,6 +1160,7 @@ i18n.execution_completed.24a1=Execution completed\:
 i18n.close_project_failure.a1d2=Failed to close the project\:
 i18n.select_folder_to_compress.915f=Please select a folder to compress
 i18n.config_file_not_found.fc87=No configuration file found
+i18n.ftp_item_not_found.60a7=FTP entry not found\: {}
 i18n.update_ssh_machine_id_failed.bd24=Updating SSH table machine id failed\:
 i18n.select_pull_code_protocol.fc24=Please select the protocol for pulling the code
 i18n.auth_directory_cannot_be_empty.21ba=The authorization directory cannot be empty
@@ -1291,6 +1328,7 @@ i18n.program_already_running.96e1=The current program is running and cannot be s
 i18n.password_change_success.8013=Password changed successfully\!
 i18n.build_resource_cleanup_failed.c4cf=Failed to clean up build resources
 i18n.incorrect_publish_method.e095=Incorrect publishing method
+i18n.ftp_directory.a790=Please enter the directory to publish to ftp
 i18n.import_low_version_data_to_new_version.247b=2. Import the exported lower version data (sql file) into the new version [Add --replace-import-h2-sql\=/xxxx.sql to the startup parameters (the path needs to be replaced with the sql file save path output by the console in the first step) ]
 i18n.incorrect_line_number.5877=Line number is incorrect
 i18n.file_deletion_event_with_details.7537=File deletion event\: {} {}
@@ -1299,6 +1337,7 @@ i18n.maven_plugin_version_required.71f1=Maven plugin version cannot be empty
 i18n.trigger_token_error_or_expired_with_code.393b=Triggered token error, or has expired\: -1
 i18n.invalid_webhooks_address.d836=WebHooks address is invalid
 i18n.project_path_auth_not_under_jpom.0e18=Project path authorization cannot be located in the Jpom directory
+i18n.ftp_rename_failed_exception.0fcc=FTP rename failed exception
 i18n.reconnect_failure.7c01=Reconnect failed
 i18n.project_operations.03d9=Project operation and maintenance
 i18n.image_not_exist.ee17=Mirror does not exist
@@ -1338,6 +1377,7 @@ i18n.refreshing_cache.c969=The cache is being refreshed, please do not refresh r
 i18n.start_executing_database_event.fc57=Start executing database events\: {}
 i18n.static_file_storage.35f6=static file storage
 i18n.project_log_is_existing_folder.a80a=The project log is an existing folder
+i18n.check_ftp_connection_failed.f7de=Detect FTP connection failure [{}] and prepare to rebuild\: {}
 i18n.project_data_workspace_id_inconsistency.7ed6=Project data workspace ID [{}] query out that the node ID is inconsistent, old data\: {}, new data\: {}
 i18n.query_workspace_error.6a0d=Failed to query the wrong workspace
 i18n.unknown_database_mode.f9e5=The current database schema is unknown
@@ -1375,8 +1415,8 @@ i18n.token_invalid_or_expired.cb96=Token error, or has expired\: -1
 i18n.list_and_query.c783=List, query
 i18n.migration_docker_cert_error.a5ea=There was an exception migrating the docker [{}] certificate
 i18n.file_downloading.7a8f=File downloading
-i18n.mark_must_contain_letters_numbers_underscores.667d=Markers can only contain letters, numbers, and underscores
 i18n.please_fill_in_runs_on.c2ff=Please fill in runsOn.
+i18n.mark_must_contain_letters_numbers_underscores.667d=Markers can only contain letters, numbers, and underscores
 i18n.create_build_task_exception.06f1=Create build task exception
 i18n.execution_interrupted.1bb6=Execution was interrupted
 i18n.event_loss_or_execution_error.7b14=Event missing or execution error\: {} {}
@@ -1401,6 +1441,7 @@ i18n.please_pass_parameter.3182=Please pass in parameters
 i18n.node_no_data_pulled.0dae=The {} node did not pull any {}, but deleted the data\: {}
 i18n.node_info_not_found.2c8c=No node information was found\:
 i18n.please_fill_in_repository_address.0cf8=Please fill in the warehouse address.
+i18n.no_ftp_correspondence.23c4=There is no corresponding FTP.
 i18n.table_without_primary_key.7392=Table has no primary key
 i18n.invalid_shard_id.46fd=Illegal sharding id
 i18n.execution_ended.b793=End of execution\: {}
@@ -1499,6 +1540,7 @@ i18n.config_file_database_config_not_parsed.47b2=Database configuration informat
 i18n.data_already_exists.0397=The imported data already exists
 i18n.please_check_in_time.3b4f=Please check in time.
 i18n.installation_success.811f=Installation successful
+i18n.timeout.e944=timeout
 i18n.cluster_management.74ea=cluster management
 i18n.type_field_value_error.14cf=Line {} Type field value error (Git/Svn)
 i18n.migrate_data.f556=migrate data
@@ -1514,6 +1556,7 @@ i18n.backup_data_trigger.a71a=backup data trigger
 i18n.file_signature_info_not_found.83bf=No file signature information
 i18n.associated_nodes_warning.64d8=The current machine is also associated with {} nodes and cannot be deleted directly (you need to unbind or delete the associated data in advance to delete it)
 i18n.build_unknown_error.dad6=An unknown error occurred in the build
+i18n.start_import_data.ea31=Start importing data\: {}
 i18n.new_version_exists_download_unavailable.4ba7=There is a new version, and the download address is not available.
 i18n.compare_backup_failure.303e=Comparison Empty project file backup failed
 i18n.upload_success_and_restart.7bc3=Upload successful and restart
@@ -1547,9 +1590,11 @@ i18n.download_exception.e616=Abnormal download file
 i18n.process_file_deletion_exception.1c6e=Handling file deletion exceptions
 i18n.trigger_auto_execute_command_template_exception.4e01=Trigger an autoexecute command template exception
 i18n.no_build.d163=No corresponding build
+i18n.ftp_management.cb91=FTP management
 i18n.start_executing_publishing_with_file_size.5039=To start publishing, the file size that needs to be published\: {}
 i18n.command_execution_exception.4ccd=Execution command exception
 i18n.operation_ip.cbd4=Operate IP
+i18n.exception.c195=abnormal
 i18n.auth_directory_cannot_contain_hierarchy.d6ca=Inclusion relationships cannot exist in the authorization directory.
 i18n.ip_authorization_interception_exception.8130=The IP authorization interception is abnormal, please check whether the configuration is correct.
 i18n.refresh_token_timeout.3291=Refresh token timeout

+ 51 - 6
modules/common/src/main/resources/i18n/messages_zh_CN.properties

@@ -1,5 +1,5 @@
 #i18n zh
-#Thu Jan 09 16:59:38 CST 2025
+#Thu Jun 12 16:41:03 CST 2025
 i18n.ssh_info_does_not_exist.5ed0=ssh 信息不存在啦
 i18n.incompatible_program_versions.5291=当前程序版本 {} 新版程序最低兼容 {} 不能直接升级
 i18n.no_projects_configured.e873=没有配置任何项目
@@ -8,6 +8,7 @@ i18n.machine_ssh_info.8dbb=机器SSH信息
 i18n.machine_info_not_exist.3468=对应的机器信息不存在
 i18n.unsupported_method.a1de=不支持的方式
 i18n.delete_local_image_failed.91fa=删除本地镜像失败
+i18n.cluster_not_grouped.8f54=当前集群还未绑定分组,不能监控 FTP 资产信息
 i18n.service_name_in_cluster_required.5446=请填写集群中的服务名
 i18n.cluster_manager_node_not_found.1cd0=没有找到集群管理节点
 i18n.distribute_id_already_exists.2168=分发id已经存在啦
@@ -34,6 +35,7 @@ i18n.start_waiting_for_data_migration.e76f=开始等待数据迁移
 i18n.empty_file_or_folder_for_publish.cae8=发布的文件或者文件夹为空,不能继续发布
 i18n.repository_type_required.9414=请选择仓库类型
 i18n.demo_account_cannot_use_feature.a1a1=演示账号不能使用该功能
+i18n.start_backup_database.e554=开始备份数据库
 i18n.async_resource_expired.2ddc=异步资源过期,需要主动关闭,{} {}
 i18n.mark_cannot_be_empty.1927=标记不能为空
 i18n.get_success.fb55=获取成功
@@ -50,9 +52,11 @@ i18n.upload_failed_no_matching_project.b219=上传失败,没有找到对应的
 i18n.no_branches_or_tags_in_repository.76b6=仓库没有任何分支或者标签
 i18n.download_file_description.10cb=下载文件 {} {} {}
 i18n.delay_build.7d62=延迟 {} 秒后开始构建
+i18n.ftp_not_exist.f9b3=不存在对应ftp
 i18n.default_cluster.38cf=默认集群
 i18n.node_and_check_project_failed.ac4b=节点与检查项目失败
 i18n.load_oauth2_config.da42=加载 oauth2 配置 :{} {}
+i18n.affected_rows.5781=影响行数
 i18n.cluster_not_bound_to_group_for_node_monitoring.1586=当前集群还未绑定分组,不能监控集群节点资产信息
 i18n.cannot_execute_error.4c29=不能执行:error
 i18n.no_environment_variable.c79f=没有对应的环境变量
@@ -114,6 +118,7 @@ i18n.no_ssh_entry_found.d0e1=没有找到对应的ssh项:{}
 i18n.build_runs_on_image_interrupted.00fd=构建 runsOn 镜像被中断
 i18n.incorrect_parameter_format.9efb=传入的参数格式不正确
 i18n.multiple_certificate_files_found.bee3=找到 2 个以上的证书文件
+i18n.file_not_exist_or_unable_to_download.b977=文件不存在或无法下载\:
 i18n.invalid_runs_on_image_name.4b96=runsOn 镜像名称不合法
 i18n.dockerfile_path_required.69ac=请填写要执行的 Dockerfile 路径
 i18n.file_upload_mode_not_configured.b3b2=没有配置文件上传模式
@@ -248,6 +253,7 @@ i18n.select_node_to_modify.6617=请选择要修改的节
 i18n.correct_dingtalk_address_required.2b4a=请输入正确钉钉地址
 i18n.query_folder_sftp_failed.9d35=查询文件夹 SFTP 失败,
 i18n.name_field_required.e0c5=第 {} 行 name 字段不能位空
+i18n.ftp_connection_failed_message.bd99=连接FTP失败:
 i18n.start_building_with_number_and_path.c41c=开始构建 \#{} 构建执行路径 \: {}
 i18n.plugin_end_log_connection_successful.9035=连接成功:插件端日志
 i18n.start_executing_upload_pre_command.fb5c=开始执行上传前命令
@@ -293,6 +299,7 @@ i18n.cannot_join_cluster_as_role.01d4=不能以 {} 角色加入集群
 i18n.not_an_enumeration.8244=不是枚举
 i18n.script_not_exist.b180=对应脚本已经不存在啦
 i18n.rebuild_success.5938=重建成功
+i18n.file_not_exist_or_unable_to_open.b045=文件不存在或无法打开\:
 i18n.already_offline.d3b5=已经离线啦
 i18n.hours.2de0=小时
 i18n.project_management.4363=项目管理
@@ -328,6 +335,7 @@ i18n.upload_progress_template.ac3f=上传文件进度\:{}/{} {}
 i18n.mark_already_exists.0ccc=标记已存在
 i18n.docker_not_found.2a2e=\ 没有找到任何 docker。可能docker tag 填写不正确,需要为 docker 配置标签
 i18n.download_remote_file_failed.fcc3=下载远程文件失败\:
+i18n.no_matching_asset_ftp.d420=不存在对应的资产FTP
 i18n.no_file_found.6f1b=没有找到 {} 文件
 i18n.distribute_result.a230=分发结果:{}
 i18n.multiple_ssh_addresses_found.b3f7=SSH 地址 {} 存在多个数据,将自动合并使用 {} SSH的配置信息
@@ -335,6 +343,7 @@ i18n.no_management_permission.fd25=您没有对应管理权限\:-2
 i18n.workspace_env_vars.f7e8=工作空间环境变量
 i18n.unsupported_mode.a3d3=暂不支持的模式:
 i18n.select_node_and_project.6021=请选择节点和项目
+i18n.initialize_sql.6691=执行初始化SQL文件
 i18n.operation_succeeded.3313=操作成功
 i18n.waiting_to_close_process.3634=等待关闭[Process]进程:{}
 i18n.url_length_exceeded.ca1c=url 长度不能超过 200
@@ -364,6 +373,7 @@ i18n.webhooks_invocation_error.9792=WebHooks 调用错误
 i18n.missing_parent_id.4331=父任务id缺失
 i18n.data_modification_time_format_incorrect.7ffe=数据修改时间格式不正确 {} {}
 i18n.cannot_delete_recent_logs.ee19=不能删除近一天相关的日志(文件修改时间)
+i18n.monitor_name.9aff=监控
 i18n.agent_jar_not_exist.28ac=Agent JAR包不存在
 i18n.parse_certificate_unknown_error.c43c=解析证书发生未知错误:
 i18n.node_info_incomplete.3b69=对应的节点信息不完整不能继续
@@ -377,12 +387,13 @@ i18n.system_logs.84aa=系统日志
 i18n.machines_ssh_data_fixed.1387=成功修复 {} 条机器 SSH 数据
 i18n.docker_label_required.b690=请填要执行 docker 标签
 i18n.no_matching_permission.09cf=未匹配到合适的权限不足
+i18n.ftp_create_folder_exception.a4fe=FTP创建文件夹异常
 i18n.data_id_does_not_exist.a566=数据id 不存在
 i18n.no_ssh_info.a8ec=没有对应 SSH 信息
 i18n.distribution_with_build_items_message.45f5=当前分发存在构建项,不能直接删除(需要提前解绑或者删除关联数据后才能删除)
 i18n.scanning_in_progress.7444=当前正在扫描中
-i18n.day_or_sky.249a=天
 i18n.please_pass_body_parameter.4e5c=请传入 body 参数
+i18n.day_or_sky.249a=天
 i18n.get_decrypt_distribution_failure.4feb=获取解密分发失败
 i18n.retention_days.3c7d=,保留天数:{}
 i18n.no_log_info.d551=还没有日志信息
@@ -401,6 +412,7 @@ i18n.node_not_enabled.10ef={} 节点未启用
 i18n.client_id_not_configured.ab8e=没有配置 clientId
 i18n.build_product_dir_not_empty.ba06=构建产物目录不能为空,长度1-200
 i18n.trigger_token_error_or_expired.8976=触发token错误,或者已经失效
+i18n.ftp_connection_failed.1f2f=连接FTP失败
 i18n.auth_info_error.c184=授权信息错误
 i18n.download_file_error.5bcd=下载文件异常\:
 i18n.rollback_ended.fb1d=执行回滚结束:{}
@@ -416,6 +428,7 @@ i18n.ssh_info.ebe6=SSH 信息
 i18n.id_already_exists.6208=id已经存在啦
 i18n.current_status.81c0=\ 当前还在:
 i18n.project_path_no_spaces.263c=项目路径不能包含空格
+i18n.ftp_asset_management.c6a5=FTP资产管理
 i18n.please_fill_in_address_of.9e02=请填写 %s 的 地址
 i18n.rsa_private_key_file_invalid.5f12=rsa 私钥文件不存在或者有误
 i18n.ssh_node_required.4566=请选择 ssh 节点
@@ -456,6 +469,7 @@ i18n.load_plugin.1f64=加载:{} 插件
 i18n.host_field_required.5c36=第 {} 行 host 字段不能位空
 i18n.container_build_interrupted.a17b=容器 build 被中断\:
 i18n.upload_action.d5a7=上传
+i18n.ftp_connection_or_operation_exception.09af=FTP 连接或操作异常
 i18n.delete_file_failure.041f=删除文件失败,请检查
 i18n.script_template_log.30cb=脚本模板日志
 i18n.no_cluster_info_found.fb40=没有找到对应的集群信息
@@ -509,6 +523,7 @@ i18n.invalid_zip_file.3092=上传的压缩包不是 Jpom [{}] 包
 i18n.publish_command_non_zero_exit_code.ea80=执行发布命令退出码非0,{}
 i18n.project_path_promotion_issue.2250=项目路径存在提升目录问题
 i18n.handle_node_deletion_script_failure_duplicate.821e=处理 {} 节点删除脚本失败{}
+i18n.ftp_folder_query_failed.0011=无法查询文件夹FTP,
 i18n.unsupported_type.7495=不支持的类型
 i18n.submit_task_queue_success.5f5b=提交任务队列成功,当前队列数:
 i18n.no_matching_process_type.b468=未匹配到合适的处理类型
@@ -567,6 +582,7 @@ i18n.select_correct_pre_publish_script.d230=请选择正确的发布前脚本
 i18n.greeting.5ecd=您好,Jpom
 i18n.ssh_connection_failed.4719=ssh连接失败
 i18n.oauth2_redirect_failed.6dcd=跳转 oauth2 失败,{} {}
+i18n.ftp_upload_failed.8298=FTP 上传失败
 i18n.data_workspace_mismatch.ae1d=数据工作空间和操作工作空间不一致
 i18n.process_file_event_exception.e8e6=处理文件事件异常
 i18n.current_docker_cluster_has_no_management_nodes_online.56cd=当前 {} docker 集群没有管理节点在线
@@ -578,6 +594,7 @@ i18n.cluster_status_code_exception.9d89=集群状态码异常:{} {}
 i18n.no_h2_data_info_for_migration.5799=没有 h2 数据信息不用迁移
 i18n.publish_success.2fff=发布成功
 i18n.system_cache.c4a8=系统缓存
+i18n.no_ftp_item.8e39=没有对应的ftp项
 i18n.distribution_machine_required.5921=请选择分发的机器
 i18n.build_call_container_exception.6e04=构建调用容器异常
 i18n.process_killed_successfully.a4c3=成功kill
@@ -586,6 +603,7 @@ i18n.auto_clear_data_errors.112f=自动清除数据错误 {} {}
 i18n.publish_directory_is_empty.79c6=发布目录为空
 i18n.file_or_directory_not_found.f03e=文件不存在或者是目录\:
 i18n.clear_file_cache_failed.5cd1=清空文件缓存失败
+i18n.database_backup_complete_path.861b=数据库备份完成,保存路径为
 i18n.file_downloading_status.c995=文件下载中:
 i18n.system_IP_authorization.9c08=系统配置IP授权
 i18n.node_failed.20d5=节点失败:
@@ -613,6 +631,7 @@ i18n.delete_failure_with_colon_and_full_stop.bc42=删除失败:
 i18n.product_directory_cannot_skip_levels.3ad4=产物目录不能越级:
 i18n.fix_null_workspace_data.4d0b=修复工作空间为 null 的数据 {} {}
 i18n.external_config_file_path.06ec=外部配置文件路径
+i18n.asset_ftp_info.3b75=资产FTP信息
 i18n.soft_link_project_department_exists.fa97=软链的项目部存在
 i18n.system_admin_not_found.6f6c=没有找到系统管理员
 i18n.docker_info.00d2=docker 信息
@@ -621,6 +640,7 @@ i18n.record_operation_log_exception.8012=记录操作日志异常
 i18n.no_file_info.db01=没有对应的文件信息
 i18n.corresponding_file_required.57b3=请选择对应到文件
 i18n.build_command_no_delete.df52=构建命令不能包含删除命令
+i18n.ftp_info_table.b177=ftp信息表
 i18n.file_merge_error.f32f=文件合并后异常,文件不完整可能被损坏
 i18n.script_info_not_found.bd8d=找不到对应的脚本信息
 i18n.cannot_modify_own_info.4036=不能修改自己的信息
@@ -637,11 +657,11 @@ i18n.check_docker_url_exception.4302=检查 docker url 异常 {}
 i18n.id_not_empty.bf1f=id不能为空
 i18n.execution_frequency.d014={} 秒执行一次
 i18n.parse_jar.a26e=解析jar
-i18n.jpom_verification_code.5b5b=Jpom 验证码
 i18n.please_fill_in_personal_token.970a=请填写个人令牌
+i18n.jpom_verification_code.5b5b=Jpom 验证码
 i18n.parent_table_info_config_error.2f52=父级表信息配置错误,
-i18n.error_message.44ce=可能是下载授权码错误或者对应授权码被禁用以及触发限流机制
 i18n.physical_node_pull_records.99df={} 物理节点拉取到 {} 个执行记录,更新 {} 个执行记录
+i18n.error_message.44ce=可能是下载授权码错误或者对应授权码被禁用以及触发限流机制
 i18n.login_success.71fa=登录成功
 i18n.clear_temp_file_failed_check_directory.7340=清除临时文件失败,请检查目录:
 i18n.request_failed_message.9c71=请求失败\: status\: %s body\: %s headers\: %s
@@ -675,6 +695,7 @@ i18n.container_build_host_config_conversion_failure.27aa=容器构建 hostConfig
 i18n.build_task_count_and_queue_count.f0b6=当前构建中任务数:{},队列中任务数:{} {}
 i18n.node_cache.d68c=节点缓存
 i18n.cluster_node_not_in_system.0645=当前集群对应的节点,不在本系统中无法退出集群
+i18n.ftp_selection.c903=请选择分发FTP项
 i18n.file_search_failed.231b=文件搜索失败
 i18n.exported_ssh_data.ce88=导出的 ssh 数据
 i18n.execution_ended_with_duration.a59b=执行结束 {}流程,耗时:{}
@@ -703,6 +724,7 @@ i18n.ssh_batch_command_execution_exception.029a=ssh 批量执行命令异常
 i18n.content_type_not_supported.81a9=不支持的 contentType
 i18n.cloud_server_network_issues.a865=云服务器的安全组配置等网络相关问题排查定位。
 i18n.start_building_image.eacd={} 开始构建镜像 {}{}
+i18n.ftp_already_exists.d66b=对应的FTP已经存在啦
 i18n.configure_correct_redirect_url.058e=请配置正确的重定向 url
 i18n.no_cache_info_with_minus_one.52f2=没有对应的缓存信息:-1
 i18n.no_node_entry_found.b1ef=没有找到对应的节点项:{}
@@ -714,6 +736,7 @@ i18n.no_docker_info_found.6d38=没有找到对应的 docker 信息
 i18n.update_docker_machine_id_failed.063d=更新 DOCKER 表机器id 失败:
 i18n.build_status_abnormal.8ca1=构建状态异常或者被取消
 i18n.node_connection_failure.896d=节点连接失败,请检查节点是否在线
+i18n.database_load_success_url.5f64=数据库加载成功,URL为
 i18n.login_name_already_exists.2511=登录名已经存在
 i18n.port_error.312e=端口错误
 i18n.account_disabled.9361=账号已经被禁用,不能使用
@@ -744,8 +767,8 @@ i18n.cron_expression_incorrect.b41a=cron 表达式不正确,
 i18n.script_template_id_required.f339=请填写脚本模板id
 i18n.start_rolling_back.f020=开始回滚:{}
 i18n.execution_exception.b0d5=执行异常
-i18n.corresponding_function.5bb5=对应功能【{}-{}】
 i18n.plugin_parameter_incorrect.a355=插件端使用参数不正确
+i18n.corresponding_function.5bb5=对应功能【{}-{}】
 i18n.operation_failed.3d94=操作失败 
 i18n.start_publishing.c0b9=开始发布中
 i18n.monitor_info.f299=监控信息
@@ -763,9 +786,12 @@ i18n.error_sql.15ff=错误 sql\:{}
 i18n.verification_method_not_configured.7358={}未配置验证方法:{}
 i18n.local_git_certificate_not_supported.b395=暂时不支持本地 git 指定证书拉取代码
 i18n.unsupported_method_with_colon.eae8=不支持的方式:
+i18n.exported_ftp_data.2b54=导出的 ftp 数据
 i18n.current_docker_has_no_cluster_info.0b52=当前 docker 没有集群信息
 i18n.cannot_delete_self.fec9=不能删除自己
 i18n.operation_monitoring_error.8036=执行操作监控错误
+i18n.ftp_read_file_failed.e738=FTP 读取文件失败
+i18n.file_exists.145b=文件夹或文件已经存在
 i18n.alias_or_token_error.d5c6=别名或者token错误,或者已经失效
 i18n.version_config_info.7b29=版本配置信息
 i18n.delete_old_package.ca95=删除旧程序包:{}
@@ -778,6 +804,7 @@ i18n.node_service_stopped_failed_restart.4307=【{}】节点的【{}】项目{}
 i18n.associated_workspace.885b=所属工作空间
 i18n.auto_clear_machine_node_stats_logs.5279=自动清理 {} 条机器节点统计日志
 i18n.unsupported_mode.501d=不支持的模式
+i18n.ftp_client_build_failure.aa55=构建 FTP 客户端失败 [{}]\: {}
 i18n.missing_script_message.af89=找不到对应的脚本
 i18n.start_migrating.20d6=开始迁移 {} {}
 i18n.incompatible_database_version.8f7b=数据库版本不兼容,需要处理跨版本升级。
@@ -802,6 +829,7 @@ i18n.configure_user_notification.250d=请配置用户通知
 i18n.operation_status_code.8231=操作状态码
 i18n.agent_jar_damaged.74a8=Agent JAR 损坏请重新上传,
 i18n.read_error.7fa5=读取错误
+i18n.import_success.ef46=导入成功:{}
 i18n.select_workspace_to_modify.ac87=请选择要修改的工作空间
 i18n.network_resource_monitoring_error.4ede=网卡资源监控异常:
 i18n.decode_failure.822e=解码失败
@@ -832,7 +860,9 @@ i18n.project_id_not_found.b87e=没有项目id
 i18n.operation_type.de9c=操作类型
 i18n.project_has_logs_cannot_migrate.2e0e=当前项目存在日志阅读,不能直接迁移
 i18n.delete_failure_with_colon.b429=删除失败\:
+i18n.no_ftp_server_correspondence.1af7=没有对应的Ftp
 i18n.tag_cannot_contain_colon.f9ae=标签不能包含 :
+i18n.ftp_import_template.8fa3=ftp导入模板
 i18n.distribute_id_already_exists_globally.6478=分发id已经存在啦,分发id需要全局唯一
 i18n.import_exception.04b6=导入第 {} 条数据异常\:{}
 i18n.script_template_log2.6b2c=脚本模版日志
@@ -895,7 +925,9 @@ i18n.no_manager_node_found.5934=当前集群未找到任何管理节点
 i18n.file_transfer_exception.bda6=转发文件异常
 i18n.upload_progress_with_units.44ad=上传文件进度\:{} {}/{} {} 
 i18n.select_node.f8a6=请选择节点
+i18n.ftp_download_file_failed.2e42=FTP 下载文件失败
 i18n.ssh_terminal_execution_log.58f1=ssh 终端执行日志
+i18n.ftp_file_manager.c52e=FTP文件管理
 i18n.push_image_interrupted.6377=push image 被中断\:
 i18n.project_monitor.d2ff=项目监控
 i18n.default_value.1aa9={} [默认]
@@ -986,6 +1018,7 @@ i18n.certificate_already_exists.adf9=当前证书已经存在啦(系统全局范
 i18n.oshi_file_system_monitoring_exception.dc24=oshi 文件系统资源监控异常
 i18n.image_cannot_be_empty.1600=镜像不能为空
 i18n.no_corresponding_node_info.cd24=没有对应到节点信息
+i18n.auto_backup_path.a16b=自动备份数据文件到路径
 i18n.start_new_thread_for_h2_database_backup.9337=启动一个新线程来执行 H2 数据库备份...启动
 i18n.node_plugin_version_required.2318=node 插件 version 不能为空
 i18n.docker_exec_terminal_process_ended.c734=[{}] docker exec 终端进程结束
@@ -1005,6 +1038,7 @@ i18n.protocol_not_supported.b906=不支持的 protocol
 i18n.force_unbind_succeeded.5bfd=强制解绑成功
 i18n.unexpected_exception.2b52=发生异常
 i18n.check_email_error.636c=检查邮箱信息错误:{}
+i18n.ftp_unauthorized_directory.df73=此ftp未授权操作此目录
 i18n.initialization_success.4725=初始化成功
 i18n.running_status.d679=运行中
 i18n.auto_clean_temp_dir.11d2=自动清理临时目录
@@ -1101,6 +1135,7 @@ i18n.upgrade_failure.4ae2=升级失败
 i18n.batch_trigger_script_exception.8fb4=服务端脚本批量触发异常
 i18n.no_branch_or_tag_message.8ae3=没有 {} 分支/标签
 i18n.general_error_message.728a=啊哦,好像哪里出错了,请稍候再试试吧~
+i18n.ftp_connection_failure.0f31=关闭 FTP 连接失败
 i18n.service_info_incomplete_with_code2.e9ca=服务信息不完整不能操作:-2
 i18n.ignored_operation.edee=忽略的操作:{}
 i18n.monitor_name_cannot_be_empty.514a=监控名称不能为空
@@ -1115,6 +1150,7 @@ i18n.select_related_data_id.7fba=请选择关联数据 ID
 i18n.node_name_required.5bdf=请填写节点名称
 i18n.client_terminated_connection.6886=客户端终止连接:{}
 i18n.close_thread_pool.4cd9=关闭 {} 线程池
+i18n.ftp_upload_file_exception.118c=FTP上传文件异常
 i18n.clear_ip_whitelist_config_success.8cf6=清除 IP 白名单配置成功
 i18n.no_corresponding_folder.621f=没有对应文件夹
 i18n.no_online_manager_node_found.05d7=当前集群未找到在线的管理节点
@@ -1125,6 +1161,7 @@ i18n.close_project_failure.a1d2=关闭项目失败:
 i18n.select_folder_to_compress.915f=请选择文件夹进行压缩
 i18n.config_file_not_found.fc87=均未找到配置文件
 i18n.update_ssh_machine_id_failed.bd24=更新 SSH 表机器id 失败:
+i18n.ftp_item_not_found.60a7=没有找到对应的ftp项:{}
 i18n.select_pull_code_protocol.fc24=请选择拉取代码的协议
 i18n.auth_directory_cannot_be_empty.21ba=授权目录不能为空
 i18n.status_not_in_progress.f410=当前状态不在进行中,
@@ -1291,6 +1328,7 @@ i18n.program_already_running.96e1=当前程序正在运行中,不能重复启
 i18n.password_change_success.8013=修改密码成功!
 i18n.build_resource_cleanup_failed.c4cf=清理构建资源失败
 i18n.incorrect_publish_method.e095=发布方法不正确
+i18n.ftp_directory.a790=请输入发布到ftp中的目录
 i18n.import_low_version_data_to_new_version.247b=2. 将导出的低版本数据( sql 文件) 导入到新版本中【启动程序参数里面添加 --replace-import-h2-sql\=/xxxx.sql (路径需要替换为第一步控制台输出的 sql 文件保存路径)】
 i18n.incorrect_line_number.5877=行号不正确
 i18n.file_deletion_event_with_details.7537=文件删除事件:{} {}
@@ -1299,6 +1337,7 @@ i18n.maven_plugin_version_required.71f1=maven 插件 version 不能为空
 i18n.trigger_token_error_or_expired_with_code.393b=触发token错误,或者已经失效\:-1
 i18n.invalid_webhooks_address.d836=WebHooks 地址不合法
 i18n.project_path_auth_not_under_jpom.0e18=项目路径授权不能位于Jpom目录下
+i18n.ftp_rename_failed_exception.0fcc=FTP重命名失败异常
 i18n.reconnect_failure.7c01=重连失败
 i18n.project_operations.03d9=项目运维
 i18n.image_not_exist.ee17=镜像不存在
@@ -1339,6 +1378,7 @@ i18n.start_executing_database_event.fc57=开始执行数据库事件:{}
 i18n.static_file_storage.35f6=静态文件存储
 i18n.project_log_is_existing_folder.a80a=项目log是一个已经存在的文件夹
 i18n.project_data_workspace_id_inconsistency.7ed6=项目数据工作空间ID[{}]查询出节点ID不一致, 旧数据\: {}, 新数据\: {}
+i18n.check_ftp_connection_failed.f7de=检测 FTP 连接失效 [{}],准备重建\: {}
 i18n.query_workspace_error.6a0d=查询错误的工作空间失败
 i18n.unknown_database_mode.f9e5=当前数据库模式未知
 i18n.secondary_directory_match.0aec={} 二级目录模糊匹配到 {} 个文件, 当前文件保留方式 {}
@@ -1375,8 +1415,8 @@ i18n.token_invalid_or_expired.cb96=token错误,或者已经失效\:-1
 i18n.list_and_query.c783=列表、查询
 i18n.migration_docker_cert_error.a5ea=迁移 docker[{}] 证书发生异常
 i18n.file_downloading.7a8f=文件下载中
-i18n.mark_must_contain_letters_numbers_underscores.667d=标记只能包含字母、数字、下划线
 i18n.please_fill_in_runs_on.c2ff=请填写runsOn。
+i18n.mark_must_contain_letters_numbers_underscores.667d=标记只能包含字母、数字、下划线
 i18n.create_build_task_exception.06f1=创建构建任务异常
 i18n.execution_interrupted.1bb6=执行被中断
 i18n.event_loss_or_execution_error.7b14=事件丢失或者执行错误:{} {}
@@ -1401,6 +1441,7 @@ i18n.please_pass_parameter.3182=请传入参数
 i18n.node_no_data_pulled.0dae={} 节点没有拉取到任何 {},但是删除了数据:{}
 i18n.node_info_not_found.2c8c=没有查询到节点信息:
 i18n.please_fill_in_repository_address.0cf8=请填写仓库地址
+i18n.no_ftp_correspondence.23c4=没有对应的FTP
 i18n.table_without_primary_key.7392=表没有主键
 i18n.invalid_shard_id.46fd=不合法的分片id
 i18n.execution_ended.b793=执行结束\:{}
@@ -1499,6 +1540,7 @@ i18n.config_file_database_config_not_parsed.47b2=未解析出配置文件中的
 i18n.data_already_exists.0397=导入的数据已经存在啦
 i18n.please_check_in_time.3b4f=请及时检查
 i18n.installation_success.811f=安装成功
+i18n.timeout.e944=超时
 i18n.cluster_management.74ea=集群管理
 i18n.type_field_value_error.14cf=第 {} 行 type 字段值错误(Git/Svn)
 i18n.migrate_data.f556=迁移数据
@@ -1514,6 +1556,7 @@ i18n.backup_data_trigger.a71a=备份数据触发器
 i18n.file_signature_info_not_found.83bf=没有文件签名信息
 i18n.associated_nodes_warning.64d8=当前机器还关联{}个节点,不能直接删除(需要提前解绑或者删除关联数据后才能删除)
 i18n.build_unknown_error.dad6=构建发生未知错误
+i18n.start_import_data.ea31=开始导入数据:{}
 i18n.new_version_exists_download_unavailable.4ba7=存在新版本,下载地址不可用
 i18n.compare_backup_failure.303e=对比清空项目文件备份失败
 i18n.upload_success_and_restart.7bc3=上传成功并重启
@@ -1547,9 +1590,11 @@ i18n.download_exception.e616=下载文件异常
 i18n.process_file_deletion_exception.1c6e=处理文件删除异常
 i18n.trigger_auto_execute_command_template_exception.4e01=触发自动执行命令模版异常
 i18n.no_build.d163=没有对应的构建
+i18n.ftp_management.cb91=FTP管理
 i18n.start_executing_publishing_with_file_size.5039=开始执行发布,需要发布的文件大小:{}
 i18n.command_execution_exception.4ccd=执行命令异常
 i18n.operation_ip.cbd4=操作IP
+i18n.exception.c195=异常
 i18n.auth_directory_cannot_contain_hierarchy.d6ca=授权目录中不能存在包含关系:
 i18n.ip_authorization_interception_exception.8130=IP授权拦截异常,请检查配置是否正确
 i18n.refresh_token_timeout.3291=刷新token超时

+ 47 - 2
modules/common/src/main/resources/i18n/messages_zh_HK.properties

@@ -1,5 +1,5 @@
 #i18n zh_HK
-#Thu Jan 09 16:59:42 CST 2025
+#Thu Jun 12 16:41:16 CST 2025
 i18n.ssh_info_does_not_exist.5ed0=ssh 信息不存在啦
 i18n.incompatible_program_versions.5291=當前程序版本 {} 新版程序最低兼容 {} 不能直接升級
 i18n.no_projects_configured.e873=沒有配置任何項目
@@ -8,6 +8,7 @@ i18n.machine_ssh_info.8dbb=機器SSH信息
 i18n.machine_info_not_exist.3468=對應的機器信息不存在
 i18n.unsupported_method.a1de=不支持的方式
 i18n.delete_local_image_failed.91fa=刪除本地鏡像失敗
+i18n.cluster_not_grouped.8f54=當前集羣還未綁定分組,不能監控 FTP 資產信息
 i18n.service_name_in_cluster_required.5446=請填寫集羣中的服務名
 i18n.cluster_manager_node_not_found.1cd0=沒有找到集羣管理節點
 i18n.distribute_id_already_exists.2168=分發id已經存在啦
@@ -34,6 +35,7 @@ i18n.start_waiting_for_data_migration.e76f=開始等待數據遷移
 i18n.empty_file_or_folder_for_publish.cae8=發佈的文件或者文件夾為空,不能繼續發佈
 i18n.demo_account_cannot_use_feature.a1a1=演示賬號不能使用該功能
 i18n.repository_type_required.9414=請選擇倉庫類型
+i18n.start_backup_database.e554=開始備份數據庫
 i18n.async_resource_expired.2ddc=異步資源過期,需要主動關閉,{} {}
 i18n.mark_cannot_be_empty.1927=標記不能為空
 i18n.get_success.fb55=獲取成功
@@ -50,9 +52,11 @@ i18n.upload_failed_no_matching_project.b219=上傳失敗,沒有找到對應的
 i18n.no_branches_or_tags_in_repository.76b6=倉庫沒有任何分支或者標籤
 i18n.download_file_description.10cb=下載文件 {} {} {}
 i18n.delay_build.7d62=延遲 {} 秒後開始構建
+i18n.ftp_not_exist.f9b3=不存在對應ftp
 i18n.default_cluster.38cf=默認集羣
 i18n.node_and_check_project_failed.ac4b=節點與檢查項目失敗
 i18n.load_oauth2_config.da42=加載 oauth2 配置 :{} {}
+i18n.affected_rows.5781=影響行數
 i18n.cluster_not_bound_to_group_for_node_monitoring.1586=當前集羣還未綁定分組,不能監控集羣節點資產信息
 i18n.cannot_execute_error.4c29=不能執行:error
 i18n.no_environment_variable.c79f=沒有對應的環境變量
@@ -114,6 +118,7 @@ i18n.no_ssh_entry_found.d0e1=沒有找到對應的ssh項:{}
 i18n.build_runs_on_image_interrupted.00fd=構建 runsOn 鏡像被中斷
 i18n.incorrect_parameter_format.9efb=傳入的參數格式不正確
 i18n.multiple_certificate_files_found.bee3=找到 2 個以上的證書文件
+i18n.file_not_exist_or_unable_to_download.b977=文件不存在或無法下載\:
 i18n.invalid_runs_on_image_name.4b96=runsOn 鏡像名稱不合法
 i18n.dockerfile_path_required.69ac=請填寫要執行的 Dockerfile 路徑
 i18n.file_upload_mode_not_configured.b3b2=沒有配置文件上傳模式
@@ -248,6 +253,7 @@ i18n.select_node_to_modify.6617=請選擇要修改的節
 i18n.correct_dingtalk_address_required.2b4a=請輸入正確釘釘地址
 i18n.query_folder_sftp_failed.9d35=查詢文件夾 SFTP 失敗,
 i18n.name_field_required.e0c5=第 {} 行 name 字段不能位空
+i18n.ftp_connection_failed_message.bd99=連接FTP失敗:
 i18n.plugin_end_log_connection_successful.9035=連接成功:插件端日誌
 i18n.start_building_with_number_and_path.c41c=開始構建 \#{} 構建執行路徑 \: {}
 i18n.start_executing_upload_pre_command.fb5c=開始執行上傳前命令
@@ -292,6 +298,7 @@ i18n.login_name_cannot_contain_chinese_and_special_characters.48a8=登錄名不
 i18n.cannot_join_cluster_as_role.01d4=不能以 {} 角色加入集羣
 i18n.not_an_enumeration.8244=不是枚舉
 i18n.script_not_exist.b180=對應腳本已經不存在啦
+i18n.file_not_exist_or_unable_to_open.b045=文件不存在或無法打開\:
 i18n.already_offline.d3b5=已經離線啦
 i18n.rebuild_success.5938=重建成功
 i18n.hours.2de0=小時
@@ -328,6 +335,7 @@ i18n.upload_progress_template.ac3f=上傳文件進度\:{}/{} {}
 i18n.mark_already_exists.0ccc=標記已存在
 i18n.docker_not_found.2a2e=\ 沒有找到任何 docker。可能docker tag 填寫不正確,需要為 docker 配置標籤
 i18n.download_remote_file_failed.fcc3=下載遠程文件失敗\:
+i18n.no_matching_asset_ftp.d420=不存在對應的資產FTP
 i18n.no_file_found.6f1b=沒有找到 {} 文件
 i18n.distribute_result.a230=分發結果:{}
 i18n.multiple_ssh_addresses_found.b3f7=SSH 地址 {} 存在多個數據,將自動合併使用 {} SSH的配置信息
@@ -335,6 +343,7 @@ i18n.no_management_permission.fd25=您沒有對應管理權限\:-2
 i18n.workspace_env_vars.f7e8=工作空間環境變量
 i18n.unsupported_mode.a3d3=暫不支持的模式:
 i18n.select_node_and_project.6021=請選擇節點和項目
+i18n.initialize_sql.6691=執行初始化SQL文件
 i18n.operation_succeeded.3313=操作成功
 i18n.url_length_exceeded.ca1c=url 長度不能超過 200
 i18n.waiting_to_close_process.3634=等待關閉[Process]進程:{}
@@ -364,6 +373,7 @@ i18n.webhooks_invocation_error.9792=WebHooks 調用錯誤
 i18n.missing_parent_id.4331=父任務id缺失
 i18n.data_modification_time_format_incorrect.7ffe=數據修改時間格式不正確 {} {}
 i18n.cannot_delete_recent_logs.ee19=不能刪除近一天相關的日誌(文件修改時間)
+i18n.monitor_name.9aff=監控
 i18n.agent_jar_not_exist.28ac=Agent JAR包不存在
 i18n.parse_certificate_unknown_error.c43c=解析證書發生未知錯誤:
 i18n.node_info_incomplete.3b69=對應的節點信息不完整不能繼續
@@ -377,6 +387,7 @@ i18n.system_logs.84aa=系統日誌
 i18n.machines_ssh_data_fixed.1387=成功修復 {} 條機器 SSH 數據
 i18n.docker_label_required.b690=請填要執行 docker 標籤
 i18n.no_matching_permission.09cf=未匹配到合適的權限不足
+i18n.ftp_create_folder_exception.a4fe=FTP創建文件夾異常
 i18n.data_id_does_not_exist.a566=數據id 不存在
 i18n.no_ssh_info.a8ec=沒有對應 SSH 信息
 i18n.distribution_with_build_items_message.45f5=當前分發存在構建項,不能直接刪除(需要提前解綁或者刪除關聯數據後才能刪除)
@@ -401,6 +412,7 @@ i18n.node_not_enabled.10ef={} 節點未啟用
 i18n.client_id_not_configured.ab8e=沒有配置 clientId
 i18n.build_product_dir_not_empty.ba06=構建產物目錄不能為空,長度1-200
 i18n.trigger_token_error_or_expired.8976=觸發token錯誤,或者已經失效
+i18n.ftp_connection_failed.1f2f=連接FTP失敗
 i18n.auth_info_error.c184=授權信息錯誤
 i18n.download_file_error.5bcd=下載文件異常\:
 i18n.rollback_ended.fb1d=執行回滾結束:{}
@@ -416,6 +428,7 @@ i18n.id_already_exists.6208=id已經存在啦
 i18n.ssh_info.ebe6=SSH 信息
 i18n.current_status.81c0=\ 當前還在:
 i18n.project_path_no_spaces.263c=項目路徑不能包含空格
+i18n.ftp_asset_management.c6a5=FTP資產管理
 i18n.please_fill_in_address_of.9e02=請填寫 %s 的 地址
 i18n.rsa_private_key_file_invalid.5f12=rsa 私鑰文件不存在或者有誤
 i18n.ssh_node_required.4566=請選擇 ssh 節點
@@ -456,6 +469,7 @@ i18n.load_plugin.1f64=加載:{} 插件
 i18n.host_field_required.5c36=第 {} 行 host 字段不能位空
 i18n.container_build_interrupted.a17b=容器 build 被中斷\:
 i18n.upload_action.d5a7=上傳
+i18n.ftp_connection_or_operation_exception.09af=FTP 連接或操作異常
 i18n.delete_file_failure.041f=刪除文件失敗,請檢查
 i18n.no_cluster_info_found.fb40=沒有找到對應的集羣信息
 i18n.script_template_log.30cb=腳本模板日誌
@@ -509,6 +523,7 @@ i18n.invalid_zip_file.3092=上傳的壓縮包不是 Jpom [{}] 包
 i18n.publish_command_non_zero_exit_code.ea80=執行發佈命令退出碼非0,{}
 i18n.project_path_promotion_issue.2250=項目路徑存在提升目錄問題
 i18n.handle_node_deletion_script_failure_duplicate.821e=處理 {} 節點刪除腳本失敗{}
+i18n.ftp_folder_query_failed.0011=無法查詢文件夾FTP,
 i18n.no_matching_process_type.b468=未匹配到合適的處理類型
 i18n.unsupported_type.7495=不支持的類型
 i18n.submit_task_queue_success.5f5b=提交任務隊列成功,當前隊列數:
@@ -567,6 +582,7 @@ i18n.select_correct_pre_publish_script.d230=請選擇正確的發佈前腳本
 i18n.greeting.5ecd=您好,Jpom
 i18n.ssh_connection_failed.4719=ssh連接失敗
 i18n.oauth2_redirect_failed.6dcd=跳轉 oauth2 失敗,{} {}
+i18n.ftp_upload_failed.8298=FTP 上傳失敗
 i18n.data_workspace_mismatch.ae1d=數據工作空間和操作工作空間不一致
 i18n.process_file_event_exception.e8e6=處理文件事件異常
 i18n.current_docker_cluster_has_no_management_nodes_online.56cd=當前 {} docker 集羣沒有管理節點在線
@@ -578,6 +594,7 @@ i18n.restart_operation.5e3a=執行重啟操作
 i18n.no_h2_data_info_for_migration.5799=沒有 h2 數據信息不用遷移
 i18n.publish_success.2fff=發佈成功
 i18n.system_cache.c4a8=系統緩存
+i18n.no_ftp_item.8e39=沒有對應的ftp項
 i18n.distribution_machine_required.5921=請選擇分發的機器
 i18n.build_call_container_exception.6e04=構建調用容器異常
 i18n.process_killed_successfully.a4c3=成功kill
@@ -586,6 +603,7 @@ i18n.auto_clear_data_errors.112f=自動清除數據錯誤 {} {}
 i18n.publish_directory_is_empty.79c6=發佈目錄為空
 i18n.file_or_directory_not_found.f03e=文件不存在或者是目錄\:
 i18n.clear_file_cache_failed.5cd1=清空文件緩存失敗
+i18n.database_backup_complete_path.861b=數據庫備份完成,保存路徑為
 i18n.file_downloading_status.c995=文件下載中:
 i18n.system_IP_authorization.9c08=系統配置IP授權
 i18n.node_failed.20d5=節點失敗:
@@ -613,6 +631,7 @@ i18n.delete_failure_with_colon_and_full_stop.bc42=刪除失敗:
 i18n.product_directory_cannot_skip_levels.3ad4=產物目錄不能越級:
 i18n.fix_null_workspace_data.4d0b=修復工作空間為 null 的數據 {} {}
 i18n.external_config_file_path.06ec=外部配置文件路徑
+i18n.asset_ftp_info.3b75=資產FTP信息
 i18n.soft_link_project_department_exists.fa97=軟鏈的項目部存在
 i18n.docker_info.00d2=docker 信息
 i18n.log_file_does_not_exist.f6c6=日誌文件不存在
@@ -621,6 +640,7 @@ i18n.no_file_info.db01=沒有對應的文件信息
 i18n.record_operation_log_exception.8012=記錄操作日誌異常
 i18n.corresponding_file_required.57b3=請選擇對應到文件
 i18n.build_command_no_delete.df52=構建命令不能包含刪除命令
+i18n.ftp_info_table.b177=ftp信息表
 i18n.file_merge_error.f32f=文件合併後異常,文件不完整可能被損壞
 i18n.script_info_not_found.bd8d=找不到對應的腳本信息
 i18n.cannot_modify_own_info.4036=不能修改自己的信息
@@ -675,6 +695,7 @@ i18n.container_build_host_config_conversion_failure.27aa=容器構建 hostConfig
 i18n.build_task_count_and_queue_count.f0b6=當前構建中任務數:{},隊列中任務數:{} {}
 i18n.node_cache.d68c=節點緩存
 i18n.cluster_node_not_in_system.0645=當前集羣對應的節點,不在本系統中無法退出集羣
+i18n.ftp_selection.c903=請選擇分發FTP項
 i18n.file_search_failed.231b=文件搜索失敗
 i18n.exported_ssh_data.ce88=導出的 ssh 數據
 i18n.execution_ended_with_duration.a59b=執行結束 {}流程,耗時:{}
@@ -703,6 +724,7 @@ i18n.ssh_batch_command_execution_exception.029a=ssh 批量執行命令異常
 i18n.content_type_not_supported.81a9=不支持的 contentType
 i18n.cloud_server_network_issues.a865=雲服務器的安全組配置等網絡相關問題排查定位。
 i18n.start_building_image.eacd={} 開始構建鏡像 {}{}
+i18n.ftp_already_exists.d66b=對應的FTP已經存在啦
 i18n.configure_correct_redirect_url.058e=請配置正確的重定向 url
 i18n.no_cache_info_with_minus_one.52f2=沒有對應的緩存信息:-1
 i18n.no_node_entry_found.b1ef=沒有找到對應的節點項:{}
@@ -714,6 +736,7 @@ i18n.no_docker_info_found.6d38=沒有找到對應的 docker 信息
 i18n.update_docker_machine_id_failed.063d=更新 DOCKER 表機器id 失敗:
 i18n.build_status_abnormal.8ca1=構建狀態異常或者被取消
 i18n.node_connection_failure.896d=節點連接失敗,請檢查節點是否在線
+i18n.database_load_success_url.5f64=數據庫加載成功,URL為
 i18n.login_name_already_exists.2511=登錄名已經存在
 i18n.port_error.312e=端口錯誤
 i18n.account_disabled.9361=賬號已經被禁用,不能使用
@@ -763,9 +786,12 @@ i18n.error_sql.15ff=錯誤 sql\:{}
 i18n.verification_method_not_configured.7358={}未配置驗證方法:{}
 i18n.local_git_certificate_not_supported.b395=暫時不支持本地 git 指定證書拉取代碼
 i18n.unsupported_method_with_colon.eae8=不支持的方式:
+i18n.exported_ftp_data.2b54=導出的 ftp 數據
 i18n.current_docker_has_no_cluster_info.0b52=當前 docker 沒有集羣信息
 i18n.cannot_delete_self.fec9=不能刪除自己
 i18n.operation_monitoring_error.8036=執行操作監控錯誤
+i18n.ftp_read_file_failed.e738=FTP 讀取文件失敗
+i18n.file_exists.145b=文件夾或文件已經存在
 i18n.alias_or_token_error.d5c6=別名或者token錯誤,或者已經失效
 i18n.version_config_info.7b29=版本配置信息
 i18n.delete_old_package.ca95=刪除舊程序包:{}
@@ -778,6 +804,7 @@ i18n.node_service_stopped_failed_restart.4307=【{}】節點的【{}】項目{}
 i18n.associated_workspace.885b=所屬工作空間
 i18n.auto_clear_machine_node_stats_logs.5279=自動清理 {} 條機器節點統計日誌
 i18n.unsupported_mode.501d=不支持的模式
+i18n.ftp_client_build_failure.aa55=構建 FTP 客户端失敗 [{}]\: {}
 i18n.missing_script_message.af89=找不到對應的腳本
 i18n.start_migrating.20d6=開始遷移 {} {}
 i18n.incompatible_database_version.8f7b=數據庫版本不兼容,需要處理跨版本升級。
@@ -802,6 +829,7 @@ i18n.configure_user_notification.250d=請配置用户通知
 i18n.operation_status_code.8231=操作狀態碼
 i18n.agent_jar_damaged.74a8=Agent JAR 損壞請重新上傳,
 i18n.read_error.7fa5=讀取錯誤
+i18n.import_success.ef46=導入成功:{}
 i18n.network_resource_monitoring_error.4ede=網卡資源監控異常:
 i18n.select_workspace_to_modify.ac87=請選擇要修改的工作空間
 i18n.decode_failure.822e=解碼失敗
@@ -832,6 +860,8 @@ i18n.project_id_not_found.b87e=沒有項目id
 i18n.operation_type.de9c=操作類型
 i18n.project_has_logs_cannot_migrate.2e0e=當前項目存在日誌閲讀,不能直接遷移
 i18n.delete_failure_with_colon.b429=刪除失敗\:
+i18n.no_ftp_server_correspondence.1af7=沒有對應的Ftp
+i18n.ftp_import_template.8fa3=ftp導入模板
 i18n.tag_cannot_contain_colon.f9ae=標籤不能包含 :
 i18n.distribute_id_already_exists_globally.6478=分發id已經存在啦,分發id需要全局唯一
 i18n.import_exception.04b6=導入第 {} 條數據異常\:{}
@@ -895,7 +925,9 @@ i18n.no_manager_node_found.5934=當前集羣未找到任何管理節點
 i18n.file_transfer_exception.bda6=轉發文件異常
 i18n.select_node.f8a6=請選擇節點
 i18n.upload_progress_with_units.44ad=上傳文件進度\:{} {}/{} {}
+i18n.ftp_download_file_failed.2e42=FTP 下載文件失敗
 i18n.ssh_terminal_execution_log.58f1=ssh 終端執行日誌
+i18n.ftp_file_manager.c52e=FTP文件管理
 i18n.project_monitor.d2ff=項目監控
 i18n.push_image_interrupted.6377=push image 被中斷\:
 i18n.default_value.1aa9={} [默認]
@@ -986,6 +1018,7 @@ i18n.certificate_already_exists.adf9=當前證書已經存在啦(系統全局範
 i18n.oshi_file_system_monitoring_exception.dc24=oshi 文件系統資源監控異常
 i18n.image_cannot_be_empty.1600=鏡像不能為空
 i18n.no_corresponding_node_info.cd24=沒有對應到節點信息
+i18n.auto_backup_path.a16b=自動備份數據文件到路徑
 i18n.node_plugin_version_required.2318=node 插件 version 不能為空
 i18n.start_new_thread_for_h2_database_backup.9337=啟動一個新線程來執行 H2 數據庫備份...啟動
 i18n.docker_exec_terminal_process_ended.c734=[{}] docker exec 終端進程結束
@@ -1005,6 +1038,7 @@ i18n.protocol_not_supported.b906=不支持的 protocol
 i18n.force_unbind_succeeded.5bfd=強制解綁成功
 i18n.unexpected_exception.2b52=發生異常
 i18n.check_email_error.636c=檢查郵箱信息錯誤:{}
+i18n.ftp_unauthorized_directory.df73=此ftp未授權操作此目錄
 i18n.initialization_success.4725=初始化成功
 i18n.running_status.d679=運行中
 i18n.auto_clean_temp_dir.11d2=自動清理臨時目錄
@@ -1101,6 +1135,7 @@ i18n.batch_trigger_script_exception.8fb4=服務端腳本批量觸發異常
 i18n.upgrade_failure.4ae2=升級失敗
 i18n.no_branch_or_tag_message.8ae3=沒有 {} 分支/標籤
 i18n.general_error_message.728a=啊哦,好像哪裏出錯了,請稍候再試試吧~
+i18n.ftp_connection_failure.0f31=關閉 FTP 連接失敗
 i18n.service_info_incomplete_with_code2.e9ca=服務信息不完整不能操作:-2
 i18n.ignored_operation.edee=忽略的操作:{}
 i18n.monitor_name_cannot_be_empty.514a=監控名稱不能為空
@@ -1115,6 +1150,7 @@ i18n.select_related_data_id.7fba=請選擇關聯數據 ID
 i18n.node_name_required.5bdf=請填寫節點名稱
 i18n.client_terminated_connection.6886=客户端終止連接:{}
 i18n.close_thread_pool.4cd9=關閉 {} 線程池
+i18n.ftp_upload_file_exception.118c=FTP上傳文件異常
 i18n.clear_ip_whitelist_config_success.8cf6=清除 IP 白名單配置成功
 i18n.no_corresponding_folder.621f=沒有對應文件夾
 i18n.no_online_manager_node_found.05d7=當前集羣未找到在線的管理節點
@@ -1124,6 +1160,7 @@ i18n.execution_completed.24a1=執行完畢\:
 i18n.close_project_failure.a1d2=關閉項目失敗:
 i18n.select_folder_to_compress.915f=請選擇文件夾進行壓縮
 i18n.config_file_not_found.fc87=均未找到配置文件
+i18n.ftp_item_not_found.60a7=沒有找到對應的ftp項:{}
 i18n.update_ssh_machine_id_failed.bd24=更新 SSH 表機器id 失敗:
 i18n.select_pull_code_protocol.fc24=請選擇拉取代碼的協議
 i18n.auth_directory_cannot_be_empty.21ba=授權目錄不能為空
@@ -1291,6 +1328,7 @@ i18n.program_already_running.96e1=當前程序正在運行中,不能重複啟
 i18n.password_change_success.8013=修改密碼成功!
 i18n.build_resource_cleanup_failed.c4cf=清理構建資源失敗
 i18n.incorrect_publish_method.e095=發佈方法不正確
+i18n.ftp_directory.a790=請輸入發佈到ftp中的目錄
 i18n.import_low_version_data_to_new_version.247b=2. 將導出的低版本數據( sql 文件) 導入到新版本中【啟動程序參數裏面添加 --replace-import-h2-sql\=/xxxx.sql (路徑需要替換為第一步控制枱輸出的 sql 文件保存路徑)】
 i18n.incorrect_line_number.5877=行號不正確
 i18n.file_deletion_event_with_details.7537=文件刪除事件:{} {}
@@ -1299,6 +1337,7 @@ i18n.maven_plugin_version_required.71f1=maven 插件 version 不能為空
 i18n.trigger_token_error_or_expired_with_code.393b=觸發token錯誤,或者已經失效\:-1
 i18n.invalid_webhooks_address.d836=WebHooks 地址不合法
 i18n.project_path_auth_not_under_jpom.0e18=項目路徑授權不能位於Jpom目錄下
+i18n.ftp_rename_failed_exception.0fcc=FTP重命名失敗異常
 i18n.reconnect_failure.7c01=重連失敗
 i18n.project_operations.03d9=項目運維
 i18n.image_not_exist.ee17=鏡像不存在
@@ -1338,6 +1377,7 @@ i18n.refreshing_cache.c969=正在刷新緩存中,請勿重複刷新
 i18n.start_executing_database_event.fc57=開始執行數據庫事件:{}
 i18n.static_file_storage.35f6=靜態文件存儲
 i18n.project_log_is_existing_folder.a80a=項目log是一個已經存在的文件夾
+i18n.check_ftp_connection_failed.f7de=檢測 FTP 連接失效 [{}],準備重建\: {}
 i18n.project_data_workspace_id_inconsistency.7ed6=項目數據工作空間ID[{}]查詢出節點ID不一致, 舊數據\: {}, 新數據\: {}
 i18n.query_workspace_error.6a0d=查詢錯誤的工作空間失敗
 i18n.unknown_database_mode.f9e5=當前數據庫模式未知
@@ -1375,8 +1415,8 @@ i18n.token_invalid_or_expired.cb96=token錯誤,或者已經失效\:-1
 i18n.list_and_query.c783=列表、查詢
 i18n.migration_docker_cert_error.a5ea=遷移 docker[{}] 證書發生異常
 i18n.file_downloading.7a8f=文件下載中
-i18n.mark_must_contain_letters_numbers_underscores.667d=標記只能包含字母、數字、下劃線
 i18n.please_fill_in_runs_on.c2ff=請填寫runsOn。
+i18n.mark_must_contain_letters_numbers_underscores.667d=標記只能包含字母、數字、下劃線
 i18n.create_build_task_exception.06f1=創建構建任務異常
 i18n.execution_interrupted.1bb6=執行被中斷
 i18n.event_loss_or_execution_error.7b14=事件丟失或者執行錯誤:{} {}
@@ -1401,6 +1441,7 @@ i18n.please_pass_parameter.3182=請傳入參數
 i18n.node_no_data_pulled.0dae={} 節點沒有拉取到任何 {},但是刪除了數據:{}
 i18n.node_info_not_found.2c8c=沒有查詢到節點信息:
 i18n.please_fill_in_repository_address.0cf8=請填寫倉庫地址
+i18n.no_ftp_correspondence.23c4=沒有對應的FTP
 i18n.table_without_primary_key.7392=表沒有主鍵
 i18n.invalid_shard_id.46fd=不合法的分片id
 i18n.execution_ended.b793=執行結束\:{}
@@ -1499,6 +1540,7 @@ i18n.config_file_database_config_not_parsed.47b2=未解析出配置文件中的
 i18n.data_already_exists.0397=導入的數據已經存在啦
 i18n.please_check_in_time.3b4f=請及時檢查
 i18n.installation_success.811f=安裝成功
+i18n.timeout.e944=超時
 i18n.cluster_management.74ea=集羣管理
 i18n.type_field_value_error.14cf=第 {} 行 type 字段值錯誤(Git/Svn)
 i18n.migrate_data.f556=遷移數據
@@ -1514,6 +1556,7 @@ i18n.backup_data_trigger.a71a=備份數據觸發器
 i18n.file_signature_info_not_found.83bf=沒有文件簽名信息
 i18n.associated_nodes_warning.64d8=當前機器還關聯{}個節點,不能直接刪除(需要提前解綁或者刪除關聯數據後才能刪除)
 i18n.build_unknown_error.dad6=構建發生未知錯誤
+i18n.start_import_data.ea31=開始導入數據:{}
 i18n.new_version_exists_download_unavailable.4ba7=存在新版本,下載地址不可用
 i18n.compare_backup_failure.303e=對比清空項目文件備份失敗
 i18n.upload_success_and_restart.7bc3=上傳成功並重啟
@@ -1547,9 +1590,11 @@ i18n.download_exception.e616=下載文件異常
 i18n.process_file_deletion_exception.1c6e=處理文件刪除異常
 i18n.trigger_auto_execute_command_template_exception.4e01=觸發自動執行命令模版異常
 i18n.no_build.d163=沒有對應的構建
+i18n.ftp_management.cb91=FTP管理
 i18n.start_executing_publishing_with_file_size.5039=開始執行發佈,需要發佈的文件大小:{}
 i18n.command_execution_exception.4ccd=執行命令異常
 i18n.operation_ip.cbd4=操作IP
+i18n.exception.c195=異常
 i18n.auth_directory_cannot_contain_hierarchy.d6ca=授權目錄中不能存在包含關係:
 i18n.ip_authorization_interception_exception.8130=IP授權攔截異常,請檢查配置是否正確
 i18n.refresh_token_timeout.3291=刷新token超時

+ 47 - 2
modules/common/src/main/resources/i18n/messages_zh_TW.properties

@@ -1,5 +1,5 @@
 #i18n zh_TW
-#Thu Jan 09 16:59:43 CST 2025
+#Thu Jun 12 16:41:17 CST 2025
 i18n.ssh_info_does_not_exist.5ed0=ssh 資訊不存在啦
 i18n.incompatible_program_versions.5291=當前程式版本 {} 新版程式最低相容 {} 不能直接升級
 i18n.no_projects_configured.e873=沒有配置任何專案
@@ -8,6 +8,7 @@ i18n.machine_ssh_info.8dbb=機器SSH資訊
 i18n.machine_info_not_exist.3468=對應的機器資訊不存在
 i18n.unsupported_method.a1de=不支援的方式
 i18n.delete_local_image_failed.91fa=刪除本地映象失敗
+i18n.cluster_not_grouped.8f54=當前叢集還未繫結分組,不能監控 FTP 資產資訊
 i18n.service_name_in_cluster_required.5446=請填寫叢集中的服務名
 i18n.cluster_manager_node_not_found.1cd0=沒有找到叢集管理節點
 i18n.distribute_id_already_exists.2168=分發id已經存在啦
@@ -34,6 +35,7 @@ i18n.start_waiting_for_data_migration.e76f=開始等待資料遷移
 i18n.empty_file_or_folder_for_publish.cae8=釋出的檔案或者資料夾為空,不能繼續釋出
 i18n.demo_account_cannot_use_feature.a1a1=演示賬號不能使用該功能
 i18n.repository_type_required.9414=請選擇倉庫型別
+i18n.start_backup_database.e554=開始備份資料庫
 i18n.async_resource_expired.2ddc=非同步資源過期,需要主動關閉,{} {}
 i18n.mark_cannot_be_empty.1927=標記不能為空
 i18n.get_success.fb55=獲取成功
@@ -50,9 +52,11 @@ i18n.upload_failed_no_matching_project.b219=上傳失敗,沒有找到對應的
 i18n.no_branches_or_tags_in_repository.76b6=倉庫沒有任何分支或者標籤
 i18n.download_file_description.10cb=下載檔案 {} {} {}
 i18n.delay_build.7d62=延遲 {} 秒後開始構建
+i18n.ftp_not_exist.f9b3=不存在對應ftp
 i18n.default_cluster.38cf=預設叢集
 i18n.node_and_check_project_failed.ac4b=節點與檢查專案失敗
 i18n.load_oauth2_config.da42=載入 oauth2 配置 :{} {}
+i18n.affected_rows.5781=影響行數
 i18n.cluster_not_bound_to_group_for_node_monitoring.1586=當前叢集還未繫結分組,不能監控叢集節點資產資訊
 i18n.cannot_execute_error.4c29=不能執行:error
 i18n.no_environment_variable.c79f=沒有對應的環境變數
@@ -114,6 +118,7 @@ i18n.no_ssh_entry_found.d0e1=沒有找到對應的ssh項:{}
 i18n.build_runs_on_image_interrupted.00fd=構建 runsOn 映象被中斷
 i18n.incorrect_parameter_format.9efb=傳入的引數格式不正確
 i18n.multiple_certificate_files_found.bee3=找到 2 個以上的證書檔案
+i18n.file_not_exist_or_unable_to_download.b977=檔案不存在或無法下載\:
 i18n.invalid_runs_on_image_name.4b96=runsOn 映象名稱不合法
 i18n.dockerfile_path_required.69ac=請填寫要執行的 Dockerfile 路徑
 i18n.file_upload_mode_not_configured.b3b2=沒有配置檔案上傳模式
@@ -248,6 +253,7 @@ i18n.select_node_to_modify.6617=請選擇要修改的節
 i18n.correct_dingtalk_address_required.2b4a=請輸入正確釘釘地址
 i18n.query_folder_sftp_failed.9d35=查詢資料夾 SFTP 失敗,
 i18n.name_field_required.e0c5=第 {} 行 name 欄位不能位空
+i18n.ftp_connection_failed_message.bd99=連線FTP失敗:
 i18n.plugin_end_log_connection_successful.9035=連線成功:外掛端日誌
 i18n.start_building_with_number_and_path.c41c=開始構建 \#{} 構建執行路徑 \: {}
 i18n.start_executing_upload_pre_command.fb5c=開始執行上傳前命令
@@ -292,6 +298,7 @@ i18n.login_name_cannot_contain_chinese_and_special_characters.48a8=登入名不
 i18n.cannot_join_cluster_as_role.01d4=不能以 {} 角色加入叢集
 i18n.not_an_enumeration.8244=不是列舉
 i18n.script_not_exist.b180=對應指令碼已經不存在啦
+i18n.file_not_exist_or_unable_to_open.b045=檔案不存在或無法開啟\:
 i18n.already_offline.d3b5=已經離線啦
 i18n.rebuild_success.5938=重建成功
 i18n.hours.2de0=小時
@@ -328,6 +335,7 @@ i18n.upload_progress_template.ac3f=上傳檔案進度\:{}/{} {}
 i18n.mark_already_exists.0ccc=標記已存在
 i18n.docker_not_found.2a2e=\ 沒有找到任何 docker。可能docker tag 填寫不正確,需要為 docker 配置標籤
 i18n.download_remote_file_failed.fcc3=下載遠端檔案失敗\:
+i18n.no_matching_asset_ftp.d420=不存在對應的資產FTP
 i18n.no_file_found.6f1b=沒有找到 {} 檔案
 i18n.distribute_result.a230=分發結果:{}
 i18n.multiple_ssh_addresses_found.b3f7=SSH 地址 {} 存在多個資料,將自動合併使用 {} SSH的配置資訊
@@ -335,6 +343,7 @@ i18n.no_management_permission.fd25=您沒有對應管理許可權\:-2
 i18n.workspace_env_vars.f7e8=工作空間環境變數
 i18n.unsupported_mode.a3d3=暫不支援的模式:
 i18n.select_node_and_project.6021=請選擇節點和專案
+i18n.initialize_sql.6691=執行初始化SQL檔案
 i18n.operation_succeeded.3313=操作成功
 i18n.url_length_exceeded.ca1c=url 長度不能超過 200
 i18n.waiting_to_close_process.3634=等待關閉[Process]程序:{}
@@ -364,6 +373,7 @@ i18n.webhooks_invocation_error.9792=WebHooks 呼叫錯誤
 i18n.missing_parent_id.4331=父任務id缺失
 i18n.data_modification_time_format_incorrect.7ffe=資料修改時間格式不正確 {} {}
 i18n.cannot_delete_recent_logs.ee19=不能刪除近一天相關的日誌(檔案修改時間)
+i18n.monitor_name.9aff=監控
 i18n.agent_jar_not_exist.28ac=Agent JAR包不存在
 i18n.parse_certificate_unknown_error.c43c=解析證書發生未知錯誤:
 i18n.node_info_incomplete.3b69=對應的節點資訊不完整不能繼續
@@ -377,6 +387,7 @@ i18n.system_logs.84aa=系統日誌
 i18n.machines_ssh_data_fixed.1387=成功修復 {} 條機器 SSH 資料
 i18n.docker_label_required.b690=請填要執行 docker 標籤
 i18n.no_matching_permission.09cf=未匹配到合適的許可權不足
+i18n.ftp_create_folder_exception.a4fe=FTP建立資料夾異常
 i18n.data_id_does_not_exist.a566=資料id 不存在
 i18n.no_ssh_info.a8ec=沒有對應 SSH 資訊
 i18n.distribution_with_build_items_message.45f5=當前分發存在構建項,不能直接刪除(需要提前解綁或者刪除關聯資料後才能刪除)
@@ -401,6 +412,7 @@ i18n.node_not_enabled.10ef={} 節點未啟用
 i18n.client_id_not_configured.ab8e=沒有配置 clientId
 i18n.build_product_dir_not_empty.ba06=構建產物目錄不能為空,長度1-200
 i18n.trigger_token_error_or_expired.8976=觸發token錯誤,或者已經失效
+i18n.ftp_connection_failed.1f2f=連線FTP失敗
 i18n.auth_info_error.c184=授權資訊錯誤
 i18n.download_file_error.5bcd=下載檔案異常\:
 i18n.rollback_ended.fb1d=執行回滾結束:{}
@@ -416,6 +428,7 @@ i18n.id_already_exists.6208=id已經存在啦
 i18n.ssh_info.ebe6=SSH 資訊
 i18n.current_status.81c0=\ 當前還在:
 i18n.project_path_no_spaces.263c=專案路徑不能包含空格
+i18n.ftp_asset_management.c6a5=FTP資產管理
 i18n.please_fill_in_address_of.9e02=請填寫 %s 的 地址
 i18n.rsa_private_key_file_invalid.5f12=rsa 私鑰檔案不存在或者有誤
 i18n.ssh_node_required.4566=請選擇 ssh 節點
@@ -456,6 +469,7 @@ i18n.load_plugin.1f64=載入:{} 外掛
 i18n.host_field_required.5c36=第 {} 行 host 欄位不能位空
 i18n.container_build_interrupted.a17b=容器 build 被中斷\:
 i18n.upload_action.d5a7=上傳
+i18n.ftp_connection_or_operation_exception.09af=FTP 連線或操作異常
 i18n.delete_file_failure.041f=刪除檔案失敗,請檢查
 i18n.no_cluster_info_found.fb40=沒有找到對應的叢集資訊
 i18n.script_template_log.30cb=指令碼模板日誌
@@ -509,6 +523,7 @@ i18n.invalid_zip_file.3092=上傳的壓縮包不是 Jpom [{}] 包
 i18n.publish_command_non_zero_exit_code.ea80=執行釋出命令退出碼非0,{}
 i18n.project_path_promotion_issue.2250=專案路徑存在提升目錄問題
 i18n.handle_node_deletion_script_failure_duplicate.821e=處理 {} 節點刪除指令碼失敗{}
+i18n.ftp_folder_query_failed.0011=無法查詢資料夾FTP,
 i18n.no_matching_process_type.b468=未匹配到合適的處理型別
 i18n.unsupported_type.7495=不支援的型別
 i18n.submit_task_queue_success.5f5b=提交任務佇列成功,當前佇列數:
@@ -567,6 +582,7 @@ i18n.select_correct_pre_publish_script.d230=請選擇正確的釋出前指令碼
 i18n.greeting.5ecd=您好,Jpom
 i18n.ssh_connection_failed.4719=ssh連線失敗
 i18n.oauth2_redirect_failed.6dcd=跳轉 oauth2 失敗,{} {}
+i18n.ftp_upload_failed.8298=FTP 上傳失敗
 i18n.data_workspace_mismatch.ae1d=資料工作空間和操作工作空間不一致
 i18n.process_file_event_exception.e8e6=處理檔案事件異常
 i18n.current_docker_cluster_has_no_management_nodes_online.56cd=當前 {} docker 叢集沒有管理節點線上
@@ -578,6 +594,7 @@ i18n.restart_operation.5e3a=執行重啟操作
 i18n.no_h2_data_info_for_migration.5799=沒有 h2 資料資訊不用遷移
 i18n.publish_success.2fff=釋出成功
 i18n.system_cache.c4a8=系統快取
+i18n.no_ftp_item.8e39=沒有對應的ftp項
 i18n.distribution_machine_required.5921=請選擇分發的機器
 i18n.build_call_container_exception.6e04=構建呼叫容器異常
 i18n.process_killed_successfully.a4c3=成功kill
@@ -586,6 +603,7 @@ i18n.auto_clear_data_errors.112f=自動清除資料錯誤 {} {}
 i18n.publish_directory_is_empty.79c6=釋出目錄為空
 i18n.file_or_directory_not_found.f03e=檔案不存在或者是目錄\:
 i18n.clear_file_cache_failed.5cd1=清空檔案快取失敗
+i18n.database_backup_complete_path.861b=資料庫備份完成,儲存路徑為
 i18n.file_downloading_status.c995=檔案下載中:
 i18n.system_IP_authorization.9c08=系統配置IP授權
 i18n.node_failed.20d5=節點失敗:
@@ -613,6 +631,7 @@ i18n.delete_failure_with_colon_and_full_stop.bc42=刪除失敗:
 i18n.product_directory_cannot_skip_levels.3ad4=產物目錄不能越級:
 i18n.fix_null_workspace_data.4d0b=修復工作空間為 null 的資料 {} {}
 i18n.external_config_file_path.06ec=外部配置檔案路徑
+i18n.asset_ftp_info.3b75=資產FTP資訊
 i18n.soft_link_project_department_exists.fa97=軟鏈的專案部存在
 i18n.docker_info.00d2=docker 資訊
 i18n.log_file_does_not_exist.f6c6=日誌檔案不存在
@@ -621,6 +640,7 @@ i18n.no_file_info.db01=沒有對應的檔案資訊
 i18n.record_operation_log_exception.8012=記錄操作日誌異常
 i18n.corresponding_file_required.57b3=請選擇對應到檔案
 i18n.build_command_no_delete.df52=構建命令不能包含刪除命令
+i18n.ftp_info_table.b177=ftp資訊表
 i18n.file_merge_error.f32f=檔案合併後異常,檔案不完整可能被損壞
 i18n.script_info_not_found.bd8d=找不到對應的指令碼資訊
 i18n.cannot_modify_own_info.4036=不能修改自己的資訊
@@ -675,6 +695,7 @@ i18n.container_build_host_config_conversion_failure.27aa=容器構建 hostConfig
 i18n.build_task_count_and_queue_count.f0b6=當前構建中任務數:{},佇列中任務數:{} {}
 i18n.node_cache.d68c=節點快取
 i18n.cluster_node_not_in_system.0645=當前叢集對應的節點,不在本系統中無法退出叢集
+i18n.ftp_selection.c903=請選擇分發FTP項
 i18n.file_search_failed.231b=檔案搜尋失敗
 i18n.exported_ssh_data.ce88=匯出的 ssh 資料
 i18n.execution_ended_with_duration.a59b=執行結束 {}流程,耗時:{}
@@ -703,6 +724,7 @@ i18n.ssh_batch_command_execution_exception.029a=ssh 批量執行命令異常
 i18n.content_type_not_supported.81a9=不支援的 contentType
 i18n.cloud_server_network_issues.a865=雲伺服器的安全組配置等網路相關問題排查定位。
 i18n.start_building_image.eacd={} 開始構建映象 {}{}
+i18n.ftp_already_exists.d66b=對應的FTP已經存在啦
 i18n.configure_correct_redirect_url.058e=請配置正確的重定向 url
 i18n.no_cache_info_with_minus_one.52f2=沒有對應的快取資訊:-1
 i18n.no_node_entry_found.b1ef=沒有找到對應的節點項:{}
@@ -714,6 +736,7 @@ i18n.no_docker_info_found.6d38=沒有找到對應的 docker 資訊
 i18n.update_docker_machine_id_failed.063d=更新 DOCKER 表機器id 失敗:
 i18n.build_status_abnormal.8ca1=構建狀態異常或者被取消
 i18n.node_connection_failure.896d=節點連線失敗,請檢查節點是否線上
+i18n.database_load_success_url.5f64=資料庫載入成功,URL為
 i18n.login_name_already_exists.2511=登入名已經存在
 i18n.port_error.312e=埠錯誤
 i18n.account_disabled.9361=賬號已經被禁用,不能使用
@@ -763,9 +786,12 @@ i18n.error_sql.15ff=錯誤 sql\:{}
 i18n.verification_method_not_configured.7358={}未配置驗證方法:{}
 i18n.local_git_certificate_not_supported.b395=暫時不支援本地 git 指定證書拉取程式碼
 i18n.unsupported_method_with_colon.eae8=不支援的方式:
+i18n.exported_ftp_data.2b54=匯出的 ftp 資料
 i18n.current_docker_has_no_cluster_info.0b52=當前 docker 沒有叢集資訊
 i18n.cannot_delete_self.fec9=不能刪除自己
 i18n.operation_monitoring_error.8036=執行操作監控錯誤
+i18n.ftp_read_file_failed.e738=FTP 讀取檔案失敗
+i18n.file_exists.145b=資料夾或檔案已經存在
 i18n.alias_or_token_error.d5c6=別名或者token錯誤,或者已經失效
 i18n.version_config_info.7b29=版本配置資訊
 i18n.delete_old_package.ca95=刪除舊程式包:{}
@@ -778,6 +804,7 @@ i18n.node_service_stopped_failed_restart.4307=【{}】節點的【{}】專案{}
 i18n.associated_workspace.885b=所屬工作空間
 i18n.auto_clear_machine_node_stats_logs.5279=自動清理 {} 條機器節點統計日誌
 i18n.unsupported_mode.501d=不支援的模式
+i18n.ftp_client_build_failure.aa55=構建 FTP 客戶端失敗 [{}]\: {}
 i18n.missing_script_message.af89=找不到對應的指令碼
 i18n.start_migrating.20d6=開始遷移 {} {}
 i18n.incompatible_database_version.8f7b=資料庫版本不相容,需要處理跨版本升級。
@@ -802,6 +829,7 @@ i18n.configure_user_notification.250d=請配置使用者通知
 i18n.operation_status_code.8231=操作狀態碼
 i18n.agent_jar_damaged.74a8=Agent JAR 損壞請重新上傳,
 i18n.read_error.7fa5=讀取錯誤
+i18n.import_success.ef46=匯入成功:{}
 i18n.network_resource_monitoring_error.4ede=網絡卡資源監控異常:
 i18n.select_workspace_to_modify.ac87=請選擇要修改的工作空間
 i18n.decode_failure.822e=解碼失敗
@@ -832,6 +860,8 @@ i18n.project_id_not_found.b87e=沒有專案id
 i18n.operation_type.de9c=操作型別
 i18n.project_has_logs_cannot_migrate.2e0e=當前專案存在日誌閱讀,不能直接遷移
 i18n.delete_failure_with_colon.b429=刪除失敗\:
+i18n.no_ftp_server_correspondence.1af7=沒有對應的Ftp
+i18n.ftp_import_template.8fa3=ftp匯入模板
 i18n.tag_cannot_contain_colon.f9ae=標籤不能包含 :
 i18n.distribute_id_already_exists_globally.6478=分發id已經存在啦,分發id需要全域性唯一
 i18n.import_exception.04b6=匯入第 {} 條資料異常\:{}
@@ -895,7 +925,9 @@ i18n.no_manager_node_found.5934=當前叢集未找到任何管理節點
 i18n.file_transfer_exception.bda6=轉發檔案異常
 i18n.select_node.f8a6=請選擇節點
 i18n.upload_progress_with_units.44ad=上傳檔案進度\:{} {}/{} {}
+i18n.ftp_download_file_failed.2e42=FTP 下載檔案失敗
 i18n.ssh_terminal_execution_log.58f1=ssh 終端執行日誌
+i18n.ftp_file_manager.c52e=FTP檔案管理
 i18n.project_monitor.d2ff=專案監控
 i18n.push_image_interrupted.6377=push image 被中斷\:
 i18n.default_value.1aa9={} [預設]
@@ -986,6 +1018,7 @@ i18n.certificate_already_exists.adf9=當前證書已經存在啦(系統全域性
 i18n.oshi_file_system_monitoring_exception.dc24=oshi 檔案系統資源監控異常
 i18n.image_cannot_be_empty.1600=映象不能為空
 i18n.no_corresponding_node_info.cd24=沒有對應到節點資訊
+i18n.auto_backup_path.a16b=自動備份資料檔案到路徑
 i18n.node_plugin_version_required.2318=node 外掛 version 不能為空
 i18n.start_new_thread_for_h2_database_backup.9337=啟動一個新執行緒來執行 H2 資料庫備份...啟動
 i18n.docker_exec_terminal_process_ended.c734=[{}] docker exec 終端程序結束
@@ -1005,6 +1038,7 @@ i18n.protocol_not_supported.b906=不支援的 protocol
 i18n.force_unbind_succeeded.5bfd=強制解綁成功
 i18n.unexpected_exception.2b52=發生異常
 i18n.check_email_error.636c=檢查郵箱資訊錯誤:{}
+i18n.ftp_unauthorized_directory.df73=此ftp未授權操作此目錄
 i18n.initialization_success.4725=初始化成功
 i18n.running_status.d679=執行中
 i18n.auto_clean_temp_dir.11d2=自動清理臨時目錄
@@ -1101,6 +1135,7 @@ i18n.batch_trigger_script_exception.8fb4=服務端指令碼批量觸發異常
 i18n.upgrade_failure.4ae2=升級失敗
 i18n.no_branch_or_tag_message.8ae3=沒有 {} 分支/標籤
 i18n.general_error_message.728a=啊哦,好像哪裡出錯了,請稍候再試試吧~
+i18n.ftp_connection_failure.0f31=關閉 FTP 連線失敗
 i18n.service_info_incomplete_with_code2.e9ca=服務資訊不完整不能操作:-2
 i18n.ignored_operation.edee=忽略的操作:{}
 i18n.monitor_name_cannot_be_empty.514a=監控名稱不能為空
@@ -1115,6 +1150,7 @@ i18n.select_related_data_id.7fba=請選擇關聯資料 ID
 i18n.node_name_required.5bdf=請填寫節點名稱
 i18n.client_terminated_connection.6886=客戶端終止連線:{}
 i18n.close_thread_pool.4cd9=關閉 {} 執行緒池
+i18n.ftp_upload_file_exception.118c=FTP上傳檔案異常
 i18n.clear_ip_whitelist_config_success.8cf6=清除 IP 白名單配置成功
 i18n.no_corresponding_folder.621f=沒有對應資料夾
 i18n.no_online_manager_node_found.05d7=當前叢集未找到線上的管理節點
@@ -1124,6 +1160,7 @@ i18n.execution_completed.24a1=執行完畢\:
 i18n.close_project_failure.a1d2=關閉專案失敗:
 i18n.select_folder_to_compress.915f=請選擇資料夾進行壓縮
 i18n.config_file_not_found.fc87=均未找到配置檔案
+i18n.ftp_item_not_found.60a7=沒有找到對應的ftp項:{}
 i18n.update_ssh_machine_id_failed.bd24=更新 SSH 表機器id 失敗:
 i18n.select_pull_code_protocol.fc24=請選擇拉取程式碼的協議
 i18n.auth_directory_cannot_be_empty.21ba=授權目錄不能為空
@@ -1291,6 +1328,7 @@ i18n.program_already_running.96e1=當前程式正在執行中,不能重複啟
 i18n.password_change_success.8013=修改密碼成功!
 i18n.build_resource_cleanup_failed.c4cf=清理構建資源失敗
 i18n.incorrect_publish_method.e095=釋出方法不正確
+i18n.ftp_directory.a790=請輸入釋出到ftp中的目錄
 i18n.import_low_version_data_to_new_version.247b=2. 將匯出的低版本資料( sql 檔案) 匯入到新版本中【啟動程式引數裡面新增 --replace-import-h2-sql\=/xxxx.sql (路徑需要替換為第一步控制檯輸出的 sql 檔案儲存路徑)】
 i18n.incorrect_line_number.5877=行號不正確
 i18n.file_deletion_event_with_details.7537=檔案刪除事件:{} {}
@@ -1299,6 +1337,7 @@ i18n.maven_plugin_version_required.71f1=maven 外掛 version 不能為空
 i18n.trigger_token_error_or_expired_with_code.393b=觸發token錯誤,或者已經失效\:-1
 i18n.invalid_webhooks_address.d836=WebHooks 地址不合法
 i18n.project_path_auth_not_under_jpom.0e18=專案路徑授權不能位於Jpom目錄下
+i18n.ftp_rename_failed_exception.0fcc=FTP重新命名失敗異常
 i18n.reconnect_failure.7c01=重連失敗
 i18n.project_operations.03d9=專案運維
 i18n.image_not_exist.ee17=映象不存在
@@ -1338,6 +1377,7 @@ i18n.refreshing_cache.c969=正在重新整理快取中,請勿重複重新整理
 i18n.start_executing_database_event.fc57=開始執行資料庫事件:{}
 i18n.static_file_storage.35f6=靜態檔案儲存
 i18n.project_log_is_existing_folder.a80a=專案log是一個已經存在的資料夾
+i18n.check_ftp_connection_failed.f7de=檢測 FTP 連線失效 [{}],準備重建\: {}
 i18n.project_data_workspace_id_inconsistency.7ed6=專案資料工作空間ID[{}]查詢出節點ID不一致, 舊資料\: {}, 新資料\: {}
 i18n.query_workspace_error.6a0d=查詢錯誤的工作空間失敗
 i18n.unknown_database_mode.f9e5=當前資料庫模式未知
@@ -1375,8 +1415,8 @@ i18n.token_invalid_or_expired.cb96=token錯誤,或者已經失效\:-1
 i18n.list_and_query.c783=列表、查詢
 i18n.migration_docker_cert_error.a5ea=遷移 docker[{}] 證書發生異常
 i18n.file_downloading.7a8f=檔案下載中
-i18n.mark_must_contain_letters_numbers_underscores.667d=標記只能包含字母、數字、下劃線
 i18n.please_fill_in_runs_on.c2ff=請填寫runsOn。
+i18n.mark_must_contain_letters_numbers_underscores.667d=標記只能包含字母、數字、下劃線
 i18n.create_build_task_exception.06f1=建立構建任務異常
 i18n.execution_interrupted.1bb6=執行被中斷
 i18n.event_loss_or_execution_error.7b14=事件丟失或者執行錯誤:{} {}
@@ -1401,6 +1441,7 @@ i18n.please_pass_parameter.3182=請傳入引數
 i18n.node_no_data_pulled.0dae={} 節點沒有拉取到任何 {},但是刪除了資料:{}
 i18n.node_info_not_found.2c8c=沒有查詢到節點資訊:
 i18n.please_fill_in_repository_address.0cf8=請填寫倉庫地址
+i18n.no_ftp_correspondence.23c4=沒有對應的FTP
 i18n.table_without_primary_key.7392=表沒有主鍵
 i18n.invalid_shard_id.46fd=不合法的分片id
 i18n.execution_ended.b793=執行結束\:{}
@@ -1499,6 +1540,7 @@ i18n.config_file_database_config_not_parsed.47b2=未解析出配置檔案中的
 i18n.data_already_exists.0397=匯入的資料已經存在啦
 i18n.please_check_in_time.3b4f=請及時檢查
 i18n.installation_success.811f=安裝成功
+i18n.timeout.e944=超時
 i18n.cluster_management.74ea=叢集管理
 i18n.type_field_value_error.14cf=第 {} 行 type 欄位值錯誤(Git/Svn)
 i18n.migrate_data.f556=遷移資料
@@ -1514,6 +1556,7 @@ i18n.backup_data_trigger.a71a=備份資料觸發器
 i18n.file_signature_info_not_found.83bf=沒有檔案簽名資訊
 i18n.associated_nodes_warning.64d8=當前機器還關聯{}個節點,不能直接刪除(需要提前解綁或者刪除關聯資料後才能刪除)
 i18n.build_unknown_error.dad6=構建發生未知錯誤
+i18n.start_import_data.ea31=開始匯入資料:{}
 i18n.new_version_exists_download_unavailable.4ba7=存在新版本,下載地址不可用
 i18n.compare_backup_failure.303e=對比清空專案檔案備份失敗
 i18n.upload_success_and_restart.7bc3=上傳成功並重啟
@@ -1547,9 +1590,11 @@ i18n.download_exception.e616=下載檔案異常
 i18n.process_file_deletion_exception.1c6e=處理檔案刪除異常
 i18n.trigger_auto_execute_command_template_exception.4e01=觸發自動執行命令模版異常
 i18n.no_build.d163=沒有對應的構建
+i18n.ftp_management.cb91=FTP管理
 i18n.start_executing_publishing_with_file_size.5039=開始執行釋出,需要釋出的檔案大小:{}
 i18n.command_execution_exception.4ccd=執行命令異常
 i18n.operation_ip.cbd4=操作IP
+i18n.exception.c195=異常
 i18n.auth_directory_cannot_contain_hierarchy.d6ca=授權目錄中不能存在包含關係:
 i18n.ip_authorization_interception_exception.8130=IP授權攔截異常,請檢查配置是否正確
 i18n.refresh_token_timeout.3291=重新整理token超時

+ 45 - 0
modules/common/src/main/resources/i18n/words.json

@@ -15,6 +15,7 @@
 	"i18n.address_not_configured.f2eb":"未配置地址",
 	"i18n.admin_account_required.31e0":"系统中的系统管理员账号数量必须存在一个以上",
 	"i18n.admin_email_not_configured.ecb8":"管理员还没有配置系统邮箱,请联系管理配置发件信息",
+	"i18n.affected_rows.5781":"影响行数",
 	"i18n.agent_jar_damaged.74a8":"Agent JAR 损坏请重新上传,",
 	"i18n.agent_jar_not_exist.28ac":"Agent JAR包不存在",
 	"i18n.agent_response_empty.cc8e":"agent 端响应内容为空",
@@ -27,6 +28,7 @@
 	"i18n.alias_or_token_error.d5c6":"别名或者token错误,或者已经失效",
 	"i18n.already_offline.d3b5":"已经离线啦",
 	"i18n.asset_cluster_and_node_mismatch.8964":"资产集群和节点不匹配",
+	"i18n.asset_ftp_info.3b75":"资产FTP信息",
 	"i18n.asset_machine_node_statistics.4a03":"资产机器节点统计",
 	"i18n.asset_monitoring_thread_pool_rejected_task.222e":"资产监控线程池拒绝了任务:{}",
 	"i18n.asset_ssh_not_exist.cd43":"不存在对应的资产SSH",
@@ -54,6 +56,7 @@
 	"i18n.authorization_exception.acc0":"{} 授权异常 {}",
 	"i18n.authorized_cannot_be_reloaded.6ece":"authorized 不能重复加载",
 	"i18n.auto_backup_h2_database.2ed0":"自动备份 h2 数据库文件,备份文件位于:{}",
+	"i18n.auto_backup_path.a16b":"自动备份数据文件到路径",
 	"i18n.auto_clean_temp_dir.11d2":"自动清理临时目录",
 	"i18n.auto_clear_data_errors.112f":"自动清除数据错误 {} {}",
 	"i18n.auto_clear_machine_node_stats_logs.5279":"自动清理 {} 条机器节点统计日志",
@@ -157,6 +160,7 @@
 	"i18n.check_docker_exception.a6d1":"检查 docker 异常",
 	"i18n.check_docker_url_exception.4302":"检查 docker url 异常 {}",
 	"i18n.check_email_error.636c":"检查邮箱信息错误:{}",
+	"i18n.check_ftp_connection_failed.f7de":"检测 FTP 连接失效 [{}],准备重建: {}",
 	"i18n.check_git_client_exception.42a3":"检查 git 客户端异常",
 	"i18n.check_passed.dce8":"检查通过",
 	"i18n.checkout_version.a586":"把版本:%s check out ",
@@ -209,6 +213,7 @@
 	"i18n.cluster_not_bound_to_group_for_node_monitoring.1586":"当前集群还未绑定分组,不能监控集群节点资产信息",
 	"i18n.cluster_not_bound_to_group_for_ssh_monitoring.c894":"当前集群还未绑定分组,不能监控 SSH 资产信息",
 	"i18n.cluster_not_exist.4098":"对应的集群不存在",
+	"i18n.cluster_not_grouped.8f54":"当前集群还未绑定分组,不能监控 FTP 资产信息",
 	"i18n.cluster_response_incorrect.c08a":"集群响应信息不正确,请确认集群地址是正确的服务端地址",
 	"i18n.cluster_status_code_exception.9d89":"集群状态码异常:{} {}",
 	"i18n.code_pull_conflict.6e8e":"拉取代码发生冲突,可以尝试清除构建或者解决仓库里面的冲突后重新操作。:",
@@ -346,12 +351,14 @@
 	"i18n.data_type_not_supported.fd03":"不支持的数据类型:",
 	"i18n.data_workspace_mismatch.ae1d":"数据工作空间和操作工作空间不一致",
 	"i18n.database_auto_backup_support.7b8f":"当前数据库不支持自动备份",
+	"i18n.database_backup_complete_path.861b":"数据库备份完成,保存路径为",
 	"i18n.database_backup_label.62d8":"数据库备份",
 	"i18n.database_connection_not_configured.c80e":"没有配置数据库连接",
 	"i18n.database_corrupted.944e":"数据库异常,可能数据库文件已经损坏(可能丢失部分数据),需要重新初始化。可以尝试在启动参数里面添加 --recover:h2db 来自动恢复,:",
 	"i18n.database_event_execution_ended.690b":"数据库 {} 事件执行结束,:{}",
 	"i18n.database_exception.4894":"数据库异常",
 	"i18n.database_exception_due_to_resources.dbf1":"数据库异常,可能因为服务器资源不足(内存、硬盘)等原因造成数据异常关闭。需要手动重启服务端来恢复,:",
+	"i18n.database_load_success_url.5f64":"数据库加载成功,URL为",
 	"i18n.database_mode_config_missing.ae5d":"数据库Mode配置缺失",
 	"i18n.database_not_initialized.e5e7":"还没有初始化数据库",
 	"i18n.database_username_not_configured.a048":"未配置(未解析到)数据库用户名",
@@ -487,6 +494,7 @@
 	"i18n.event_script_does_not_exist.e726":"事件脚本不存在:{} {}",
 	"i18n.event_script_interrupted.8c79":"事件脚本中断:",
 	"i18n.event_type_not_supported.e9c3":"不支持的事件类型:{}",
+	"i18n.exception.c195":"异常",
 	"i18n.exclusion_success.7d46":"剔除成功",
 	"i18n.execute.1a6a":"执行",
 	"i18n.execute_dsl_script_exception.0882":"执行 DSL 脚本异常:{}",
@@ -514,6 +522,7 @@
 	"i18n.exit_successful.8150":"退出成功",
 	"i18n.export_image_exception.cb1c":"导出镜像异常",
 	"i18n.export_low_version_data.f1aa":"1. 导出低版本数据 【启动程序参数里面添加 --backup-h2】",
+	"i18n.exported_ftp_data.2b54":"导出的 ftp 数据",
 	"i18n.exported_project_data.fd1f":"导出的项目数据 ",
 	"i18n.exported_repo_data.bac5":"导出的 仓库信息 数据 ",
 	"i18n.exported_ssh_data.ce88":"导出的 ssh 数据",
@@ -532,6 +541,7 @@
 	"i18n.file_download_failed.7983":"文件下载失败:",
 	"i18n.file_downloading.7a8f":"文件下载中",
 	"i18n.file_downloading_status.c995":"文件下载中:",
+	"i18n.file_exists.145b":"文件夹或文件已经存在",
 	"i18n.file_format_not_supported.eac4":"不支持的文件格式",
 	"i18n.file_full_path.16cc":"文件全路径:{}",
 	"i18n.file_id_missing.0e39":"文件 ID 缺失",
@@ -547,6 +557,8 @@
 	"i18n.file_name_not_found.b0ed":"没有文件名",
 	"i18n.file_not_exist.5091":"对应的文件不存在",
 	"i18n.file_not_exist.ea6a":"不存在对应的文件",
+	"i18n.file_not_exist_or_unable_to_download.b977":"文件不存在或无法下载:",
+	"i18n.file_not_exist_or_unable_to_open.b045":"文件不存在或无法打开:",
 	"i18n.file_not_found.d952":"文件不存在",
 	"i18n.file_or_directory_not_found.f03e":"文件不存在或者是目录:",
 	"i18n.file_publish_task_record.edc4":"文件发布任务记录",
@@ -581,6 +593,29 @@
 	"i18n.forbidden_operation_time_range.92bf":"【禁止操作】当前时段禁止执行 {} 至 {}",
 	"i18n.force_unbind_succeeded.5bfd":"强制解绑成功",
 	"i18n.free_script.7760":"自由脚本",
+	"i18n.ftp_already_exists.d66b":"对应的FTP已经存在啦",
+	"i18n.ftp_asset_management.c6a5":"FTP资产管理",
+	"i18n.ftp_client_build_failure.aa55":"构建 FTP 客户端失败 [{}]: {}",
+	"i18n.ftp_connection_failed.1f2f":"连接FTP失败",
+	"i18n.ftp_connection_failed_message.bd99":"连接FTP失败:",
+	"i18n.ftp_connection_failure.0f31":"关闭 FTP 连接失败",
+	"i18n.ftp_connection_or_operation_exception.09af":"FTP 连接或操作异常",
+	"i18n.ftp_create_folder_exception.a4fe":"FTP创建文件夹异常",
+	"i18n.ftp_directory.a790":"请输入发布到ftp中的目录",
+	"i18n.ftp_download_file_failed.2e42":"FTP 下载文件失败",
+	"i18n.ftp_file_manager.c52e":"FTP文件管理",
+	"i18n.ftp_folder_query_failed.0011":"无法查询文件夹FTP,",
+	"i18n.ftp_import_template.8fa3":"ftp导入模板",
+	"i18n.ftp_info_table.b177":"ftp信息表",
+	"i18n.ftp_item_not_found.60a7":"没有找到对应的ftp项:{}",
+	"i18n.ftp_management.cb91":"FTP管理",
+	"i18n.ftp_not_exist.f9b3":"不存在对应ftp",
+	"i18n.ftp_read_file_failed.e738":"FTP 读取文件失败",
+	"i18n.ftp_rename_failed_exception.0fcc":"FTP重命名失败异常",
+	"i18n.ftp_selection.c903":"请选择分发FTP项",
+	"i18n.ftp_unauthorized_directory.df73":"此ftp未授权操作此目录",
+	"i18n.ftp_upload_failed.8298":"FTP 上传失败",
+	"i18n.ftp_upload_file_exception.118c":"FTP上传文件异常",
 	"i18n.fuzzy_match_files.139d":"{} 模糊匹配到 {} 个文件",
 	"i18n.general_error_message.728a":"啊哦,好像哪里出错了,请稍候再试试吧~",
 	"i18n.general_execution_exception.62e9":"执行异常:",
@@ -647,6 +682,7 @@
 	"i18n.import_save_failure.001a":"导入第 {} 条数据保存失败:{}",
 	"i18n.import_save_project_exception.cdbe":"导入保存项目异常",
 	"i18n.import_success.b6d1":"导入成功",
+	"i18n.import_success.ef46":"导入成功:{}",
 	"i18n.import_success_message.2df3":"导入成功(编码格式:{}),更新 {} 条数据,因为节点分发/项目副本忽略 {} 条数据",
 	"i18n.import_success_with_count.22b9":"导入成功,添加 {} 条数据,修改 {} 条数据",
 	"i18n.import_success_with_details.a4a0":"导入成功(编码格式:{}),添加 {} 条数据,修改 {} 条数据",
@@ -677,6 +713,7 @@
 	"i18n.initialization_failure.19e9":"初始化失败:",
 	"i18n.initialization_success.4725":"初始化成功",
 	"i18n.initialize_database_failure.2ef9":"初始化数据库失败 {}",
+	"i18n.initialize_sql.6691":"执行初始化SQL文件",
 	"i18n.initialize_user_failure.fe27":"初始化用户失败",
 	"i18n.initialize_workspace.bc97":"初始化{}工作空间",
 	"i18n.install_id_does_not_exist.6aee":"数据错误,安装 ID 不存在",
@@ -803,6 +840,7 @@
 	"i18n.monitor_docker_exception_detail.e334":"监控 docker[{}] 异常 {}",
 	"i18n.monitor_docker_timeout.b03b":"监控 docker[{}] 超时 {}",
 	"i18n.monitor_info.f299":"监控信息",
+	"i18n.monitor_name.9aff":"监控",
 	"i18n.monitor_name_cannot_be_empty.514a":"监控名称不能为空",
 	"i18n.monitor_node_exception.6ff1":"监控 {} 节点异常 {}",
 	"i18n.monitor_ssh_exception.e9ce":"监控 ssh[{}] 异常",
@@ -904,6 +942,9 @@
 	"i18n.no_file_info.db01":"没有对应的文件信息",
 	"i18n.no_files_in_project_directory.108e":"项目目录没有任何文件,请先到项目文件管理中上传文件",
 	"i18n.no_files_in_zip.1af6":"压缩包里没有任何文件",
+	"i18n.no_ftp_correspondence.23c4":"没有对应的FTP",
+	"i18n.no_ftp_item.8e39":"没有对应的ftp项",
+	"i18n.no_ftp_server_correspondence.1af7":"没有对应的Ftp",
 	"i18n.no_get_id_method.2a65":"没有  getId 方法",
 	"i18n.no_h2_data_info_for_migration.5799":"没有 h2 数据信息不用迁移",
 	"i18n.no_implemented_feature.af80":"没有实现该功能",
@@ -919,6 +960,7 @@
 	"i18n.no_management_permission.fd25":"您没有对应管理权限:-2",
 	"i18n.no_management_permission2.35d4":"您没有对应管理权限:-3",
 	"i18n.no_manager_node_found.5934":"当前集群未找到任何管理节点",
+	"i18n.no_matching_asset_ftp.d420":"不存在对应的资产FTP",
 	"i18n.no_matching_data_found.fe9d":"未找到匹配的数据",
 	"i18n.no_matching_files.b7a6":"{} 没有匹配到任何文件",
 	"i18n.no_matching_permission.09cf":"未匹配到合适的权限不足",
@@ -1452,6 +1494,7 @@
 	"i18n.ssh_with_build_items_message.0f6d":"当前ssh存在构建项,不能直接删除(需要提前解绑或者删除关联数据后才能删除)",
 	"i18n.ssl_connection_failed.e26c":"SSL 无法连接(请检查证书信任的地址和配置的 docker host 是否一致):",
 	"i18n.start_async_download.78cc":"开始异步下载",
+	"i18n.start_backup_database.e554":"开始备份数据库",
 	"i18n.start_building.1039":"开始构建中",
 	"i18n.start_building_image.eacd":"{} 开始构建镜像 {}{}",
 	"i18n.start_building_with_number_and_path.c41c":"开始构建 #{} 构建执行路径 : {}",
@@ -1474,6 +1517,7 @@
 	"i18n.start_executing_upload_post_command.1c1b":"开始执行上传后命令",
 	"i18n.start_executing_upload_pre_command.fb5c":"开始执行上传前命令",
 	"i18n.start_execution.00d7":"开始执行",
+	"i18n.start_import_data.ea31":"开始导入数据:{}",
 	"i18n.start_loading_database.b040":"开始加载 {} 数据库",
 	"i18n.start_migrating.20d6":"开始迁移 {} {}",
 	"i18n.start_migrating_h2_data_to.f478":"开始迁移 h2 数据到 {}",
@@ -1549,6 +1593,7 @@
 	"i18n.temp_folder_file_count.8e31":"临时文件夹累计文件数:{},处理成功数:{}",
 	"i18n.temporary_result_file_does_not_exist.1c7e":"临时结果文件不存在: {}",
 	"i18n.test_result.8441":"测试结果:{} {}",
+	"i18n.timeout.e944":"超时",
 	"i18n.token_invalid_or_expired.cb96":"token错误,或者已经失效:-1",
 	"i18n.token_parse_failed.cadf":"token 解析失败:",
 	"i18n.too_many_attempts.d88d":"尝试次数太多,请稍后再来",

+ 4 - 5
modules/common/src/test/resources/baidubce_translate.txt

@@ -1,19 +1,18 @@
 You are the service that converts a user request JSON into a new (user-expected) JSON object based on the following JavaScript-defined JSON object:
 
-```
 // 将下面json中的 根据`值`的含义将 `key` 转为语义化且简短的首字母为小写的小驼峰英文变量名替换无意义字符串
 // 此处进行替换,禁止出现 k1 k2 k3
-const template = {
+```json
+{
  "key1": "string1",
  "key2": "string2",
 }
 ```
 
 The following is a user request:
-```
-const template = {REQUEST_STR}
+```json
+{REQUEST_STR}
 ```
 
 // 要求输出的请求和结果分开,不要输出到一个代码片段并且是 json 代码片段
-// 输出的变量请使用 var 语法
 // 输出的 json 结果中不要声明注释

+ 6 - 0
modules/server/pom.xml

@@ -168,6 +168,12 @@
             <groupId>cn.hutool</groupId>
             <artifactId>hutool-cache</artifactId>
         </dependency>
+
+        <dependency>
+            <groupId>commons-net</groupId>
+            <artifactId>commons-net</artifactId>
+            <version>3.11.1</version>
+        </dependency>
     </dependencies>
     <build>
         <resources>

+ 4 - 0
modules/server/src/main/java/org/dromara/jpom/build/BuildExtraModule.java

@@ -73,6 +73,10 @@ public class BuildExtraModule extends BaseModel {
      * 发布到ssh中的目录
      */
     private String releasePath;
+    /**
+     * 发布到ftp中的目录
+     */
+    private String releaseFtpPath;
     /**
      * 工作空间 ID
      */

+ 81 - 11
modules/server/src/main/java/org/dromara/jpom/build/ReleaseManage.java

@@ -7,6 +7,7 @@
  * 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.build;
 
 import cn.hutool.core.collection.CollUtil;
@@ -22,6 +23,7 @@ import cn.hutool.core.util.NumberUtil;
 import cn.hutool.core.util.ObjectUtil;
 import cn.hutool.core.util.StrUtil;
 import cn.hutool.crypto.SecureUtil;
+import cn.hutool.extra.ftp.Ftp;
 import cn.hutool.extra.spring.SpringUtil;
 import cn.hutool.extra.ssh.JschUtil;
 import cn.keepbx.jpom.model.JsonMessage;
@@ -30,6 +32,21 @@ import com.alibaba.fastjson2.JSONArray;
 import com.alibaba.fastjson2.JSONObject;
 import com.jcraft.jsch.ChannelSftp;
 import com.jcraft.jsch.Session;
+import java.io.File;
+import java.io.IOException;
+import java.io.InputStream;
+import java.nio.charset.Charset;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.Optional;
+import java.util.Set;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.Future;
+import java.util.function.Consumer;
+import java.util.stream.Collectors;
 import lombok.Builder;
 import lombok.Lombok;
 import lombok.extern.slf4j.Slf4j;
@@ -39,14 +56,17 @@ import org.dromara.jpom.common.forward.NodeForward;
 import org.dromara.jpom.common.forward.NodeUrl;
 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.model.MachineSshModel;
 import org.dromara.jpom.func.assets.server.MachineDockerServer;
+import org.dromara.jpom.func.assets.server.MachineFtpServer;
 import org.dromara.jpom.func.assets.server.ScriptLibraryServer;
 import org.dromara.jpom.func.files.service.FileStorageService;
 import org.dromara.jpom.model.AfterOpt;
 import org.dromara.jpom.model.BaseEnum;
 import org.dromara.jpom.model.EnvironmentMapBuilder;
 import org.dromara.jpom.model.data.BuildInfoModel;
+import org.dromara.jpom.model.data.FtpModel;
 import org.dromara.jpom.model.data.NodeModel;
 import org.dromara.jpom.model.data.SshModel;
 import org.dromara.jpom.model.docker.DockerInfoModel;
@@ -60,6 +80,7 @@ import org.dromara.jpom.plugins.JschUtils;
 import org.dromara.jpom.service.docker.DockerInfoService;
 import org.dromara.jpom.service.docker.DockerSwarmInfoService;
 import org.dromara.jpom.service.node.NodeService;
+import org.dromara.jpom.service.node.ftp.FtpService;
 import org.dromara.jpom.service.node.ssh.SshService;
 import org.dromara.jpom.system.ExtConfigBean;
 import org.dromara.jpom.system.JpomRuntimeException;
@@ -69,17 +90,6 @@ import org.dromara.jpom.util.MySftp;
 import org.dromara.jpom.util.StringUtil;
 import org.springframework.util.Assert;
 
-import java.io.File;
-import java.io.IOException;
-import java.io.InputStream;
-import java.nio.charset.Charset;
-import java.util.*;
-import java.util.concurrent.ConcurrentHashMap;
-import java.util.concurrent.ExecutionException;
-import java.util.concurrent.Future;
-import java.util.function.Consumer;
-import java.util.stream.Collectors;
-
 /**
  * 发布管理
  *
@@ -110,6 +120,8 @@ public class ReleaseManage {
     private static BuildExtConfig buildExtConfig;
     private static FileStorageService fileStorageService;
     private static ScriptLibraryServer scriptLibraryServer;
+    private static MachineFtpServer machineFtpServer;
+
 
     private void loadService() {
         buildExecuteService = ObjectUtil.defaultIfNull(buildExecuteService, () -> SpringUtil.getBean(BuildExecuteService.class));
@@ -118,6 +130,7 @@ public class ReleaseManage {
         buildExtConfig = ObjectUtil.defaultIfNull(buildExtConfig, () -> SpringUtil.getBean(BuildExtConfig.class));
         fileStorageService = ObjectUtil.defaultIfNull(fileStorageService, () -> SpringUtil.getBean(FileStorageService.class));
         scriptLibraryServer = ObjectUtil.defaultIfNull(scriptLibraryServer, () -> SpringUtil.getBean(ScriptLibraryServer.class));
+        machineFtpServer = ObjectUtil.defaultIfNull(machineFtpServer, () -> SpringUtil.getBean(MachineFtpServer.class));
     }
 
     private Integer getRealBuildNumberId() {
@@ -198,6 +211,8 @@ public class ReleaseManage {
             return this.localCommand();
         } else if (releaseMethod == BuildReleaseMethod.DockerImage.getCode()) {
             return this.doDockerImage();
+        } else if (releaseMethod == BuildReleaseMethod.Ftp.getCode()) {
+            this.doFtp();
         } else if (releaseMethod == BuildReleaseMethod.No.getCode()) {
             return null;
         } else {
@@ -503,6 +518,61 @@ public class ReleaseManage {
         }
     }
 
+
+    /**
+     * ftp发布
+     *
+     * @throws IOException
+     */
+    private void doFtp() {
+        String releaseMethodDataId = this.buildExtraModule.getReleaseMethodDataId();
+        FtpService ftpService = SpringUtil.getBean(FtpService.class);
+        List<String> strings = StrUtil.splitTrim(releaseMethodDataId, StrUtil.COMMA);
+        for (String releaseMethodDataIdItem : strings) {
+            FtpModel item = ftpService.getByKey(releaseMethodDataIdItem, false);
+            if (item == null) {
+                logRecorder.systemError(I18nMessageUtil.get("i18n.ftp_item_not_found.60a7"), releaseMethodDataIdItem);
+                continue;
+            }
+
+            String releasePath = this.buildExtraModule.getReleaseFtpPath();
+
+            if (StrUtil.isEmpty(releasePath)) {
+                logRecorder.systemWarning(I18nMessageUtil.get("i18n.publish_directory_is_empty.79c6"));
+            } else {
+                logRecorder.system(I18nMessageUtil.get("i18n.start_upload_ftp_file.20be"), DateUtil.now(), item.getName(), System.lineSeparator());
+
+                MachineFtpModel machineFtpModel = ftpService.getMachineFtpModel(item);
+                try (Ftp ftp = machineFtpServer.getFtpClient(machineFtpModel)) {
+
+
+                    String prefix = "";
+                    if (!StrUtil.startWith(releasePath, StrUtil.SLASH)) {
+                        prefix = ftp.pwd();
+                    }
+                    String normalizePath = FileUtil.normalize(prefix + StrUtil.SLASH + releasePath);
+                    if (this.buildExtraModule.isClearOld()) {
+                        try {
+                            if (ftp.exist(normalizePath)) {
+                                ftp.delDir(normalizePath);
+                            }
+                        } catch (Exception e) {
+                            if (!StrUtil.startWithIgnoreCase(e.getMessage(), "No such file")) {
+                                logRecorder.error(I18nMessageUtil.get("i18n.clear_build_product_failed.edd4"), e);
+                            }
+                        }
+                    }
+                    ftpService.uploadWithProgress(ftp, this.resultFile, normalizePath, logRecorder, 5);
+                } catch (Exception e) {
+                    throw new RuntimeException(e);
+                }
+                logRecorder.system(I18nMessageUtil.get("i18n.start_upload_ftp_file.20be"), DateUtil.now(), item.getName(), System.lineSeparator());
+
+                logRecorder.system("{} {} ftp upload done", DateUtil.now(), item.getName());
+            }
+        }
+    }
+
     /**
      * 差异上传发布
      *

+ 29 - 0
modules/server/src/main/java/org/dromara/jpom/configuration/AssetsConfig.java

@@ -41,6 +41,10 @@ public class AssetsConfig {
      * ssh 资产配置
      */
     private SshConfig ssh;
+    /**
+     * ftp 资产配置
+     */
+    private FtpConfig ftp;
     /**
      * docker 资产配置
      */
@@ -53,6 +57,13 @@ public class AssetsConfig {
         });
     }
 
+    public FtpConfig getFtp() {
+        return ObjectUtil.defaultIfNull(this.ftp, () -> {
+            this.ftp = new FtpConfig();
+            return ftp;
+        });
+    }
+
     public DockerConfig getDocker() {
         return ObjectUtil.defaultIfNull(this.docker, () -> {
             this.docker = new DockerConfig();
@@ -78,6 +89,24 @@ public class AssetsConfig {
 
     }
 
+    /**
+     * ftp 配置
+     */
+    @Data
+    @ConfigurationProperties("jpom.assets.ftp")
+    public static class FtpConfig {
+
+        /**
+         * 监控频率
+         */
+        private String monitorCron;
+        /**
+         * 禁用监控的分组名 (如果想禁用所有配置 * 即可)
+         */
+        private List<String> disableMonitorGroupName;
+
+    }
+
     /**
      * docker 配置
      */

+ 44 - 1
modules/server/src/main/java/org/dromara/jpom/controller/build/BuildInfoController.java

@@ -39,6 +39,7 @@ import org.dromara.jpom.model.AfterOpt;
 import org.dromara.jpom.model.BaseEnum;
 import org.dromara.jpom.model.PageResultDto;
 import org.dromara.jpom.model.data.BuildInfoModel;
+import org.dromara.jpom.model.data.FtpModel;
 import org.dromara.jpom.model.data.RepositoryModel;
 import org.dromara.jpom.model.data.SshModel;
 import org.dromara.jpom.model.enums.BuildReleaseMethod;
@@ -51,6 +52,7 @@ import org.dromara.jpom.service.dblog.BuildInfoService;
 import org.dromara.jpom.service.dblog.DbBuildHistoryLogService;
 import org.dromara.jpom.service.dblog.RepositoryService;
 import org.dromara.jpom.service.docker.DockerInfoService;
+import org.dromara.jpom.service.node.ftp.FtpService;
 import org.dromara.jpom.service.node.ssh.SshService;
 import org.dromara.jpom.service.script.ScriptServer;
 import org.dromara.jpom.util.CommandUtil;
@@ -78,6 +80,7 @@ public class BuildInfoController extends BaseServerController {
 
     private final DbBuildHistoryLogService dbBuildHistoryLogService;
     private final SshService sshService;
+    private final FtpService ftpService;
     private final BuildInfoService buildInfoService;
     private final RepositoryService repositoryService;
     private final BuildExecuteService buildExecuteService;
@@ -87,7 +90,7 @@ public class BuildInfoController extends BaseServerController {
     protected final MachineDockerServer machineDockerServer;
 
     public BuildInfoController(DbBuildHistoryLogService dbBuildHistoryLogService,
-                               SshService sshService,
+                               SshService sshService, FtpService ftpService,
                                BuildInfoService buildInfoService,
                                RepositoryService repositoryService,
                                BuildExecuteService buildExecuteService,
@@ -97,6 +100,7 @@ public class BuildInfoController extends BaseServerController {
                                MachineDockerServer machineDockerServer) {
         this.dbBuildHistoryLogService = dbBuildHistoryLogService;
         this.sshService = sshService;
+        this.ftpService = ftpService;
         this.buildInfoService = buildInfoService;
         this.repositoryService = repositoryService;
         this.buildExecuteService = buildExecuteService;
@@ -262,6 +266,8 @@ public class BuildInfoController extends BaseServerController {
             // dockerSwarmId default
             String dockerSwarmId = this.formatDocker(jsonObject, request);
             jsonObject.put("releaseMethodDataId", dockerSwarmId);
+        } else if (releaseMethod1 == BuildReleaseMethod.Ftp){
+            this.formatFtp(jsonObject, request);
         }
         // 检查关联数据ID
         buildInfoModel.setReleaseMethodDataId(jsonObject.getString("releaseMethodDataId"));
@@ -355,6 +361,43 @@ public class BuildInfoController extends BaseServerController {
         jsonObject.put("releaseMethodDataId", releaseMethodDataId);
     }
 
+    /**
+     * 验证构建信息
+     * 当发布方式为【FTP】的时候
+     *
+     * @param jsonObject 配置信息
+     */
+    private void formatFtp(JSONObject jsonObject, HttpServletRequest request) {
+        // 发布方式
+        String releaseMethodDataId = jsonObject.getString("releaseMethodDataId_6");
+        Assert.hasText(releaseMethodDataId, I18nMessageUtil.get("i18n.ftp_selection.c903"));
+
+        String releasePath = jsonObject.getString("releasePath");
+        Assert.hasText(releasePath, I18nMessageUtil.get("i18n.ftp_directory.a790"));
+        releasePath = FileUtil.normalize(releasePath);
+        List<String> strings = StrUtil.splitTrim(releaseMethodDataId, StrUtil.COMMA);
+        for (String releaseMethodDataIdItem : strings) {
+            FtpModel ftpServiceItem = ftpService.getByKey(releaseMethodDataIdItem, request);
+            Assert.notNull(ftpServiceItem, I18nMessageUtil.get("i18n.no_ftp_item.8e39"));
+            //
+            if (releasePath.startsWith(StrUtil.SLASH)) {
+                // 以根路径开始
+                List<String> fileDirs = ftpServiceItem.fileDirs();
+                Assert.notEmpty(fileDirs, ftpServiceItem.getName() + I18nMessageUtil.get("i18n.ftp_unauthorized_directory.df73"));
+
+                boolean find = false;
+                for (String fileDir : fileDirs) {
+                    if (FileUtil.isSub(new File(fileDir), new File(releasePath))) {
+                        find = true;
+                    }
+                }
+                Assert.state(find, ftpServiceItem.getName() + I18nMessageUtil.get("i18n.ftp_unauthorized_directory.df73"));
+            }
+
+        }
+        jsonObject.put("releaseMethodDataId", releaseMethodDataId);
+    }
+
     private String formatDocker(JSONObject jsonObject, HttpServletRequest request) {
         // 发布命令
         String dockerfile = jsonObject.getString("dockerfile");

+ 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);
+    }
+}

+ 34 - 9
modules/server/src/main/java/org/dromara/jpom/controller/script/ScriptLogController.java

@@ -37,6 +37,7 @@ import org.springframework.web.bind.annotation.RestController;
 
 import javax.servlet.http.HttpServletRequest;
 import java.io.File;
+import java.util.List;
 
 /**
  * @author bwcx_jzy
@@ -51,8 +52,7 @@ public class ScriptLogController extends BaseServerController {
     private final ScriptExecuteLogServer scriptExecuteLogServer;
     private final ScriptServer scriptServer;
 
-    public ScriptLogController(ScriptExecuteLogServer scriptExecuteLogServer,
-                               ScriptServer scriptServer) {
+    public ScriptLogController(ScriptExecuteLogServer scriptExecuteLogServer, ScriptServer scriptServer) {
         this.scriptExecuteLogServer = scriptExecuteLogServer;
         this.scriptServer = scriptServer;
     }
@@ -78,9 +78,7 @@ public class ScriptLogController extends BaseServerController {
      */
     @RequestMapping(value = "del_log", method = RequestMethod.POST, produces = MediaType.APPLICATION_JSON_VALUE)
     @Feature(method = MethodFeature.DEL)
-    public IJsonMessage<Object> delLog(@ValidatorItem() String id,
-                                       @ValidatorItem() String executeId,
-                                       HttpServletRequest request) {
+    public IJsonMessage<Object> delLog(@ValidatorItem() String id, @ValidatorItem() String executeId, HttpServletRequest request) {
         ScriptModel item = null;
         try {
             item = scriptServer.getByKeyAndGlobal(id, request, "ignore");
@@ -96,6 +94,36 @@ public class ScriptLogController extends BaseServerController {
         return JsonMessage.success(I18nMessageUtil.get("i18n.delete_success.0007"));
     }
 
+    /**
+     * 批量删除日志
+     *
+     * @param ids id+
+     * @return json
+     */
+    @RequestMapping(value = "batch_del_log", method = RequestMethod.POST, produces = MediaType.APPLICATION_JSON_VALUE)
+    @Feature(method = MethodFeature.DEL)
+    public IJsonMessage<Object> delLog(@ValidatorItem() String ids, HttpServletRequest request) {
+        List<String> list = StrUtil.splitTrim(ids, StrUtil.COMMA);
+        ScriptModel item = null;
+        for (String itemId : list) {
+            String[] list1 = StrUtil.splitToArray(itemId, StrUtil.COLON);
+            String id = list1[1];
+            String executeId = list1[0];
+            try {
+                item = scriptServer.getByKeyAndGlobal(id, request, "ignore");
+            } catch (IllegalArgumentException | IllegalStateException e) {
+                if (!StrUtil.equals("ignore", e.getMessage())) {
+                    throw e;
+                }
+            }
+            File logFile = item == null ? ScriptModel.logFile(id, executeId) : item.logFile(executeId);
+            boolean fastDel = CommandUtil.systemFastDel(logFile);
+            Assert.state(!fastDel, I18nMessageUtil.get("i18n.delete_log_file_failure.bf0b"));
+            scriptExecuteLogServer.delByKey(executeId);
+        }
+        return JsonMessage.success(I18nMessageUtil.get("i18n.delete_success.0007"));
+    }
+
     /**
      * 获取的日志
      *
@@ -106,10 +134,7 @@ public class ScriptLogController extends BaseServerController {
      */
     @RequestMapping(value = "log", method = RequestMethod.POST, produces = MediaType.APPLICATION_JSON_VALUE)
     @Feature(method = MethodFeature.LIST)
-    public IJsonMessage<JSONObject> getNowLog(@ValidatorItem() String id,
-                                              @ValidatorItem() String executeId,
-                                              @ValidatorItem(value = ValidatorRule.POSITIVE_INTEGER, msg = "i18n.line_number_error.c65d") int line,
-                                              HttpServletRequest request) {
+    public IJsonMessage<JSONObject> getNowLog(@ValidatorItem() String id, @ValidatorItem() String executeId, @ValidatorItem(value = ValidatorRule.POSITIVE_INTEGER, msg = "i18n.line_number_error.c65d") int line, HttpServletRequest request) {
         ScriptModel item = scriptServer.getByKey(id, request);
         Assert.notNull(item, I18nMessageUtil.get("i18n.no_data_found.4ffb"));
         File logFile = item.logFile(executeId);

+ 12 - 9
modules/server/src/main/java/org/dromara/jpom/controller/ssh/CommandLogController.java

@@ -10,6 +10,7 @@
 package org.dromara.jpom.controller.ssh;
 
 import cn.hutool.core.io.FileUtil;
+import cn.hutool.core.util.StrUtil;
 import cn.hutool.extra.servlet.ServletUtil;
 import cn.keepbx.jpom.IJsonMessage;
 import cn.keepbx.jpom.model.JsonMessage;
@@ -77,13 +78,16 @@ public class CommandLogController extends BaseServerController {
     @RequestMapping(value = "del", method = RequestMethod.POST, produces = MediaType.APPLICATION_JSON_VALUE)
     @Feature(method = MethodFeature.DEL)
     public IJsonMessage<String> del(String id, HttpServletRequest request) {
-        CommandExecLogModel execLogModel = commandExecLogService.getByKey(id, request);
-        Assert.notNull(execLogModel, I18nMessageUtil.get("i18n.no_record.ff41"));
-        File logFile = execLogModel.logFile();
-        boolean fastDel = CommandUtil.systemFastDel(logFile);
-        Assert.state(!fastDel, I18nMessageUtil.get("i18n.log_file_cleanup_failed.3a3b"));
-        //
-        commandExecLogService.delByKey(id);
+        List<String> list = StrUtil.splitTrim(id, StrUtil.COMMA);
+        for (String id1 : list) {
+            CommandExecLogModel execLogModel = commandExecLogService.getByKey(id1, request);
+            Assert.notNull(execLogModel, I18nMessageUtil.get("i18n.no_record.ff41"));
+            File logFile = execLogModel.logFile();
+            boolean fastDel = CommandUtil.systemFastDel(logFile);
+            Assert.state(!fastDel, I18nMessageUtil.get("i18n.log_file_cleanup_failed.3a3b"));
+            //
+            commandExecLogService.delByKey(id1);
+        }
         return JsonMessage.success(I18nMessageUtil.get("i18n.operation_succeeded.3313"));
     }
 
@@ -137,8 +141,7 @@ public class CommandLogController extends BaseServerController {
      */
     @RequestMapping(value = "log", method = RequestMethod.POST, produces = MediaType.APPLICATION_JSON_VALUE)
     @Feature(method = MethodFeature.LIST)
-    public IJsonMessage<JSONObject> log(@ValidatorItem(value = ValidatorRule.NOT_BLANK, msg = "i18n.no_data.1ac0") String id,
-                                        @ValidatorItem(value = ValidatorRule.POSITIVE_INTEGER, msg = "i18n.line_number_error.c65d") int line, HttpServletRequest request) {
+    public IJsonMessage<JSONObject> log(@ValidatorItem(value = ValidatorRule.NOT_BLANK, msg = "i18n.no_data.1ac0") String id, @ValidatorItem(value = ValidatorRule.POSITIVE_INTEGER, msg = "i18n.line_number_error.c65d") int line, HttpServletRequest request) {
         CommandExecLogModel item = commandExecLogService.getByKey(id, request);
         Assert.notNull(item, I18nMessageUtil.get("i18n.no_data_found.4ffb"));
 

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

@@ -0,0 +1,635 @@
+/*
+ * 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.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.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 = machineFtpServer.getFtpClient(machineFtpModel);
+             InputStream inputStream = ftp.getClient().retrieveFileStream(normalize);
+             ByteArrayOutputStream outputStream = new ByteArrayOutputStream()) {
+
+            if (inputStream == null) {
+                throw new RuntimeException(I18nMessageUtil.get("i18n.file_not_exist_or_unable_to_open.b045") + normalize);
+            }
+
+            IoUtil.copy(inputStream, outputStream);
+            ftp.getClient().completePendingCommand();
+
+            return outputStream.toString(charset.name());
+
+        } catch (IOException e) {
+            throw new RuntimeException(I18nMessageUtil.get("i18n.ftp_read_file_failed.e738"), 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);
+
+        try (Ftp ftp = machineFtpServer.getFtpClient(machineFtpModel)) {
+            // 直接upload到原来目录 会覆盖更新文件
+            boolean success = ftp.upload(normalizeDir, file);
+            if (!success) {
+                throw new RuntimeException(I18nMessageUtil.get("i18n.ftp_upload_failed.8298"));
+            }
+        } catch (Exception e) {
+            throw new RuntimeException(I18nMessageUtil.get("i18n.ftp_connection_or_operation_exception.09af"), 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);
+
+        InputStream inputStream = null;
+        try (Ftp ftp = machineFtpServer.getFtpClient(machineFtpModel)) {
+            inputStream = ftp.getClient().retrieveFileStream(normalize);
+
+            if (inputStream == null) {
+                throw new RuntimeException(I18nMessageUtil.get("i18n.file_not_exist_or_unable_to_download.b977") + normalize);
+            }
+
+            IoUtil.copy(inputStream, response.getOutputStream());
+            ftp.getClient().completePendingCommand(); // 关键!
+
+        } catch (IOException e) {
+            throw new RuntimeException(I18nMessageUtil.get("i18n.ftp_download_file_failed.2e42"), e);
+        } finally {
+            IoUtil.close(inputStream);
+        }
+    }
+
+    /**
+     * 查询文件夹下所有文件
+     *
+     * @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 = machineFtpServer.getFtpClient(ftpModel)) {
+            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(I18nMessageUtil.get("i18n.ftp_folder_query_failed.0011") + 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 (Exception 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 = machineFtpServer.getFtpClient(ftpModel)) {
+            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(I18nMessageUtil.get("i18n.ftp_connection_failed.1f2f"), 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 = machineFtpServer.getFtpClient(machineFtpModel)) {
+                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 = machineFtpServer.getFtpClient(machineFtpModel)) {
+                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(I18nMessageUtil.get("i18n.ftp_rename_failed_exception.0fcc"), e);
+                return new JsonMessage<>(400, I18nMessageUtil.get("i18n.ftp_rename_failed_exception.0fcc") + 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 = machineFtpServer.getFtpClient(machineFtpModel)) {
+                // 保存路径
+                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(I18nMessageUtil.get("i18n.ftp_upload_file_exception.118c"), e);
+                return new JsonMessage<>(400, I18nMessageUtil.get("i18n.ftp_upload_file_exception.118c") + 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 = machineFtpServer.getFtpClient(machineFtpModel)) {
+                String remotePath = FileUtil.normalize(allowPathParent + StrUtil.SLASH + nextPath + StrUtil.SLASH + name);
+
+                File filePath = null;
+                try {
+                    if (ftp.exist(remotePath)) {
+                        return new JsonMessage<>(400, I18nMessageUtil.get("i18n.file_exists.145b"));
+                    }
+                    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(I18nMessageUtil.get("i18n.ftp_create_folder_exception.a4fe"), e);
+                            return new JsonMessage<>(500, I18nMessageUtil.get("i18n.ftp_create_folder_exception.a4fe") + 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 (Exception e) {
+                throw new RuntimeException(e);
+            }
+        });
+    }
+
+
+    public String getPermissionString(FTPFile file) {
+
+        // 否则按照 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();
+    }
+}

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

@@ -0,0 +1,457 @@
+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.nio.charset.Charset;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+import java.util.function.Function;
+import javax.servlet.http.HttpServletRequest;
+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.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.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;
+import org.springframework.web.multipart.MultipartFile;
+
+/**
+ * @author bwcx_jzy
+ * @since 2024/8/31
+ */
+@RestController
+@RequestMapping(value = "/system/assets/ftp")
+@Feature(cls = ClassFeature.SYSTEM_ASSETS_MACHINE_SSH)
+@SystemPermission
+@Slf4j
+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, 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)
+    @Feature(method = MethodFeature.LIST)
+    public IJsonMessage<PageResultDto<MachineFtpModel>> listJson(HttpServletRequest request) {
+        PageResultDto<MachineFtpModel> pageResultDto = machineFtpServer.listPage(request);
+        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 user               用户名
+     * @param password           密码
+     * @param serverLanguageCode 服务器语言
+     * @param systemKey          服务器系统关键词
+     * @param port               端口
+     * @param charset            编码格式
+     * @param id                 ID
+     * @return json
+     */
+    @PostMapping(value = "edit", 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,
+                                     @ValidatorItem(value = ValidatorRule.NOT_BLANK, msg = "i18n.host_cannot_be_empty.644a") String host,
+                                     @ValidatorItem(value = ValidatorRule.NOT_BLANK, msg = "i18n.parameter_error_user_cannot_be_empty.9239") String user,
+                                     String password,
+                                     @ValidatorItem(value = ValidatorRule.POSITIVE_INTEGER, msg = "i18n.parameter_error_port_error.810d") int port,
+                                     String charset,
+                                     String id,
+                                     Integer timeout,
+                                     String allowEditSuffix,
+                                     String serverLanguageCode,
+                                     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, I18nMessageUtil.get("i18n.ftp_not_exist.f9b3"));
+        }
+
+        MachineFtpModel model = new MachineFtpModel();
+        model.setId(id);
+        model.setGroupName(groupName);
+        model.setHost(host);
+        model.setServerLanguageCode(serverLanguageCode);
+        model.setMode(mode);
+        model.setSystemKey(systemKey);
+        // 如果密码传递不为空就设置值 因为上面已经判断了只有修改的情况下 password 才可能为空
+        Opt.ofBlankAble(password).ifPresent(model::setPassword);
+
+        // 获取允许编辑的后缀
+        List<String> allowEditSuffixList = AgentWhitelist.parseToList(allowEditSuffix, I18nMessageUtil.get("i18n.suffix_cannot_be_empty.ec72"));
+        model.allowEditSuffix(allowEditSuffixList);
+        model.setPort(port);
+        model.setUser(user);
+        model.setName(name);
+        model.setTimeout(timeout);
+        try {
+            Charset.forName(charset);
+            model.setCharset(charset);
+        } catch (Exception e) {
+            return new JsonMessage<>(405, I18nMessageUtil.get("i18n.correct_encoding_format_required.1f7f") + e.getMessage());
+        }
+        // 判断重复
+        Entity entity = Entity.create();
+        entity.set("host", model.getHost());
+        entity.set("port", model.getPort());
+        entity.set(DialectUtil.wrapField("user"), model.getUser());
+        Opt.ofBlankAble(id).ifPresent(s -> entity.set("id", StrUtil.format(" <> {}", s)));
+        boolean exists = machineFtpServer.exists(entity);
+        Assert.state(!exists, I18nMessageUtil.get("i18n.ftp_already_exists.d66b"));
+
+
+        MachineFtpModel byKey = machineFtpServer.getByKey(id,false);
+        Optional.ofNullable(byKey).ifPresent(item -> {
+            model.setPassword(StrUtil.emptyToDefault(model.getPassword(), item.getPassword()));
+        });
+
+        // 测试连接
+        try (Ftp ftp = new Ftp(machineFtpServer.toFtpConfig(model),
+            EnumUtil.fromString(FtpMode.class, mode, FtpMode.Active)))  {
+            ftp.pwd();
+        } catch (Exception e) {
+            log.error(I18nMessageUtil.get("i18n.ftp_connection_failed.1f2f"), e);
+            return new JsonMessage<>(500, I18nMessageUtil.get("i18n.ftp_connection_failed_message.bd99") + e.getMessage());
+        }
+
+        model.setStatus(1);
+        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, I18nMessageUtil.get("i18n.no_ftp_correspondence.23c4"));
+            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);
+            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 prefix = I18nMessageUtil.get("i18n.ftp_import_template.8fa3");
+        String fileName = prefix + ".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 = I18nMessageUtil.get("i18n.exported_ftp_data.2b54");
+        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
+     */
+    @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);
+    }
+
+
+}

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

@@ -0,0 +1,75 @@
+/*
+ * 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.common.i18n.I18nMessageUtil;
+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, I18nMessageUtil.get("i18n.no_ftp_server_correspondence.1af7"));
+        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 连接方式

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

@@ -0,0 +1,115 @@
+/*
+ * 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.model;
+
+import cn.hutool.core.util.CharsetUtil;
+import cn.hutool.extra.ftp.FtpConfig;
+import cn.hutool.extra.ftp.FtpMode;
+import com.alibaba.fastjson2.JSONArray;
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+import lombok.NoArgsConstructor;
+import org.dromara.jpom.db.TableName;
+import org.dromara.jpom.model.BaseGroupNameModel;
+
+import java.util.List;
+
+/**
+ * @author bwcx_jzy
+ * @see FtpConfig
+ * @since 2024/08/31
+ */
+@EqualsAndHashCode(callSuper = true)
+@TableName(value = "MACHINE_FTP_INFO",
+    nameKey = "i18n.asset_ftp_info.3b75")
+@Data
+@NoArgsConstructor
+public class MachineFtpModel extends BaseGroupNameModel {
+
+    /**
+     * 主机地址
+     */
+    private String host;
+    /**
+     * 端口
+     */
+    private Integer port;
+    /**
+     * 登录账号
+     */
+    private String user;
+    /**
+     * 账号密码
+     */
+    private String password;
+    /**
+     * 编码格式
+     */
+    private String charset;
+    /**
+     * 超时时间
+     */
+    private Integer timeout;
+    /**
+     * 设置服务器语言
+     */
+    private String serverLanguageCode;
+
+    /**
+     * 设置服务器系统关键词
+     */
+    private String systemKey;
+    /**
+     * 模式
+     */
+    private String mode;
+    /**
+     * ftp连接状态
+     * <p>
+     * 状态{0,无法连接,1 正常,2 禁用监控}
+     */
+    private Integer status;
+    /**
+     * 状态消息
+     */
+    private String statusMsg;
+    /**
+     * 允许编辑后缀
+     */
+    private String allowEditSuffix;
+
+    public MachineFtpModel(String id) {
+        setId(id);
+    }
+
+    public void allowEditSuffix(List<String> allowEditSuffix) {
+        if (allowEditSuffix == null) {
+            this.allowEditSuffix = null;
+        } else {
+            this.allowEditSuffix = JSONArray.toJSONString(allowEditSuffix);
+        }
+    }
+
+    public FtpConfig toFtpConfig() {
+        FtpConfig ftpConfig = new FtpConfig();
+        ftpConfig.setHost(host);
+        ftpConfig.setPort(port);
+        ftpConfig.setUser(user);
+        ftpConfig.setPassword(password);
+        ftpConfig.setCharset(CharsetUtil.parse(charset));
+        if (timeout != null) {
+            ftpConfig.setSoTimeout(timeout * 1000);
+            ftpConfig.setConnectionTimeout(timeout * 1000);
+        }
+        ftpConfig.setSystemKey(systemKey);
+        ftpConfig.setServerLanguageCode(serverLanguageCode);
+        return ftpConfig;
+    }
+}

+ 245 - 0
modules/server/src/main/java/org/dromara/jpom/func/assets/server/MachineFtpServer.java

@@ -0,0 +1,245 @@
+package org.dromara.jpom.func.assets.server;
+
+import cn.hutool.core.collection.CollUtil;
+import cn.hutool.core.io.IoUtil;
+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.FtpException;
+import cn.hutool.extra.ftp.FtpMode;
+import cn.keepbx.jpom.event.IAsyncLoad;
+import java.time.Duration;
+import java.util.Map;
+import java.util.Optional;
+import java.util.concurrent.ConcurrentHashMap;
+import javax.annotation.PreDestroy;
+import lombok.Lombok;
+import lombok.extern.slf4j.Slf4j;
+import org.apache.commons.net.ftp.FTPClient;
+import org.dromara.jpom.common.Const;
+import org.dromara.jpom.common.ServerConst;
+import org.dromara.jpom.common.i18n.I18nMessageUtil;
+import org.dromara.jpom.configuration.AssetsConfig;
+import org.dromara.jpom.cron.CronUtils;
+import org.dromara.jpom.func.assets.AssetsExecutorPoolService;
+import org.dromara.jpom.func.assets.model.MachineFtpModel;
+import org.dromara.jpom.func.system.service.ClusterInfoService;
+import org.dromara.jpom.plugin.IWorkspaceEnvPlugin;
+import org.dromara.jpom.plugin.PluginFactory;
+import org.dromara.jpom.service.h2db.BaseDbService;
+import org.springframework.stereotype.Service;
+
+import java.util.List;
+
+/**
+ * @author bwcx_jzy
+ * @since 2024/8/31
+ */
+@Service
+@Slf4j
+public class MachineFtpServer extends BaseDbService<MachineFtpModel> implements IAsyncLoad, Task {
+
+    private static final String CRON_ID = "ftp-monitor";
+
+    private final ClusterInfoService clusterInfoService;
+    private final AssetsConfig.FtpConfig ftpConfig;
+    private final AssetsExecutorPoolService assetsExecutorPoolService;
+
+    private final Map<String, Ftp> ftpConnectionPool = new ConcurrentHashMap<>();
+
+
+    public MachineFtpServer(ClusterInfoService clusterInfoService,
+                            AssetsConfig assetsConfig,
+                            AssetsExecutorPoolService assetsExecutorPoolService) {
+        this.clusterInfoService = clusterInfoService;
+        this.ftpConfig = assetsConfig.getFtp();
+        this.assetsExecutorPoolService = assetsExecutorPoolService;
+    }
+
+    /**
+     * 转换为配置对象
+     *
+     * @param model ftp model
+     * @return config
+     */
+    public FtpConfig toFtpConfig(MachineFtpModel model) {
+        String workspaceId = ServerConst.WORKSPACE_GLOBAL;
+        FtpConfig config = model.toFtpConfig();
+        String password = config.getPassword();
+        String user = config.getUser();
+        // 转化密码字段
+        IWorkspaceEnvPlugin plugin = (IWorkspaceEnvPlugin) PluginFactory.getPlugin(IWorkspaceEnvPlugin.PLUGIN_NAME);
+        try {
+            user = plugin.convertRefEnvValue(workspaceId, user);
+            password = plugin.convertRefEnvValue(workspaceId, password);
+        } catch (Exception e) {
+            throw Lombok.sneakyThrow(e);
+        }
+        config.setUser(user);
+        config.setPassword(password);
+        return config;
+    }
+
+    @Override
+    protected void fillInsert(MachineFtpModel model) {
+        super.fillInsert(model);
+        model.setGroupName(StrUtil.emptyToDefault(model.getGroupName(), Const.DEFAULT_GROUP_NAME.get()));
+        model.setStatus(ObjectUtil.defaultIfNull(model.getStatus(), 0));
+    }
+
+    @Override
+    protected void fillSelectResult(MachineFtpModel data) {
+        if (data == null) {
+            return;
+        }
+        if (!StrUtil.startWithIgnoreCase(data.getPassword(), ServerConst.REF_WORKSPACE_ENV)) {
+            // 隐藏密码字段
+            data.setPassword(null);
+        }
+    }
+
+    @Override
+    public void execute() {
+        Entity entity = new Entity();
+        if (clusterInfoService.isMultiServer()) {
+            String linkGroup = clusterInfoService.getCurrent().getLinkGroup();
+            List<String> linkGroups = StrUtil.splitTrim(linkGroup, StrUtil.COMMA);
+            if (CollUtil.isEmpty(linkGroups)) {
+                log.warn(I18nMessageUtil.get("i18n.cluster_not_grouped.8f54"));
+                return;
+            }
+            entity.set("groupName", linkGroups);
+        }
+        List<MachineFtpModel> list = this.listByEntity(entity, false);
+        if (CollUtil.isEmpty(list)) {
+            return;
+        }
+        this.checkList(list);
+    }
+
+
+    private void checkList(List<MachineFtpModel> monitorModels) {
+        monitorModels.forEach(monitorModel -> assetsExecutorPoolService.execute(() -> this.updateMonitor(monitorModel)));
+    }
+
+    /**
+     * 执行监控 ftp
+     *
+     * @param machineFtpModel 资产 ftp
+     */
+    private void updateMonitor(MachineFtpModel machineFtpModel) {
+        List<String> monitorGroupName = ftpConfig.getDisableMonitorGroupName();
+        if (CollUtil.containsAny(monitorGroupName, CollUtil.newArrayList(machineFtpModel.getGroupName(), "*"))) {
+            // 禁用监控
+            if (machineFtpModel.getStatus() != null && machineFtpModel.getStatus() == 2) {
+                // 不需要更新
+                return;
+            }
+            this.updateStatus(machineFtpModel.getId(), 2, I18nMessageUtil.get("i18n.disable_monitoring.4615"));
+            return;
+        }
+        log.warn("machineFtpModel:{}",machineFtpModel);
+        try (Ftp ftp = getFtpClient(machineFtpModel)) {
+            ftp.pwd();
+            //
+            this.updateStatus(machineFtpModel.getId(), 1, "");
+        } catch (Exception e) {
+            String message = e.getMessage();
+            String s = I18nMessageUtil.get("i18n.monitor_name.9aff");
+            if (StrUtil.containsIgnoreCase(message, "timeout")) {
+                String s1 = I18nMessageUtil.get("i18n.timeout.e944");
+                log.error("{} ftp[{}] {} {}", s, machineFtpModel.getName(), s1, message);
+            } else {
+                String s1 = I18nMessageUtil.get("i18n.exception.c195");
+                log.error("{} ftp[{}] {}", s, machineFtpModel.getName(), s1, e);
+            }
+            this.updateStatus(machineFtpModel.getId(), 0, message);
+        }
+    }
+
+    /**
+     * 更新 ftp状态
+     *
+     * @param id     ID
+     * @param status 状态值
+     * @param msg    错误消息
+     */
+    private void updateStatus(String id, int status, String msg) {
+        MachineFtpModel model = new MachineFtpModel();
+        model.setId(id);
+        model.setStatus(status);
+        model.setStatusMsg(msg);
+        super.updateById(model);
+    }
+
+    @Override
+    public void startLoad() {
+        String monitorCron = ftpConfig.getMonitorCron();
+        String cron = Opt.ofBlankAble(monitorCron).orElse("0 0/1 * * * ?");
+        CronUtils.add(CRON_ID, cron, () -> MachineFtpServer.this);
+    }
+
+
+    /**
+     * 获取或创建 Ftp 连接,自动重连失效连接
+     *
+     * @param machineFtpModel 模型
+     * @return Ftp 实例
+     */
+    public synchronized Ftp getFtpClient(MachineFtpModel machineFtpModel) {
+        MachineFtpModel model = this.getByKey(machineFtpModel.getId(), false);
+        Optional.ofNullable(model).ifPresent(machineSshModel -> {
+            machineFtpModel.setPassword(StrUtil.emptyToDefault(machineFtpModel.getPassword(), machineFtpModel.getPassword()));
+        });
+        String id = machineFtpModel.getId();
+        Ftp ftp = ftpConnectionPool.get(id);
+        try {
+            if (ftp != null) {
+                ftp.pwd();
+                return ftp;
+            }
+        } catch (Exception e) {
+            System.out.println(machineFtpModel);
+            log.warn(I18nMessageUtil.get("i18n.check_ftp_connection_failed.f7de"), id, e.getMessage());
+            IoUtil.close(ftp);
+        }
+
+        try {
+            FtpConfig config = toFtpConfig(machineFtpModel);
+            Ftp tmpFtp = new Ftp(config, EnumUtil.fromString(FtpMode.class, machineFtpModel.getMode(), FtpMode.Active));
+
+            tmpFtp.pwd();
+
+            ftpConnectionPool.put(id, tmpFtp);
+            return tmpFtp;
+        } catch (FtpException e) {
+            log.error(I18nMessageUtil.get("i18n.ftp_client_build_failure.aa55"), id, e.getMessage());
+            throw Lombok.sneakyThrow(e);
+        }
+//        return ftp;
+    }
+
+    /**
+     * 销毁所有连接(服务销毁时调用)
+     */
+/*    @PreDestroy
+    public void destroy() {
+        synchronized (ftpConnectionPool) {
+            for (Ftp ftp : ftpConnectionPool.values()) {
+                try {
+                    if (ftp != null && ftp.getClient().isAvailable()) {
+                        ftp.close();
+                    }
+                } catch (Exception e) {
+                    log.warn(I18nMessageUtil.get("i18n.ftp_connection_failure.0f31"), e);
+                }
+            }
+            ftpConnectionPool.clear();
+        }
+    }*/
+}

+ 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 = "i18n.ftp_info_table.b177")
+@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);
+        }
+    }
+}

+ 1 - 0
modules/server/src/main/java/org/dromara/jpom/model/enums/BuildReleaseMethod.java

@@ -25,6 +25,7 @@ public enum BuildReleaseMethod implements BaseEnum {
 	Ssh(3, "SSH"),
 	LocalCommand(4, "本地命令行"),
 	DockerImage(5, "Docker镜像"),
+	Ftp(6, "FTP"),
 	;
 	private final int code;
 	private final String desc;

+ 15 - 3
modules/server/src/main/java/org/dromara/jpom/permission/ClassFeature.java

@@ -9,9 +9,11 @@
  */
 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.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;
@@ -21,7 +23,13 @@ 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;
@@ -29,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;
@@ -44,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;
-
 /**
  * 功能模块
  *
@@ -68,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(() -> I18nMessageUtil.get("i18n.ftp_management.cb91"), FtpService.class),
+    FTP_FILE(() -> I18nMessageUtil.get("i18n.ftp_file_manager.c52e"), 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),
@@ -77,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
      */
@@ -98,6 +109,7 @@ public enum ClassFeature {
     SYSTEM_UPGRADE(() -> I18nMessageUtil.get("i18n.online_upgrade.da8c")),
     SYSTEM_ASSETS_MACHINE(() -> I18nMessageUtil.get("i18n.machine_asset_management.36ea"), MachineNodeServer.class),
     SYSTEM_ASSETS_MACHINE_SSH(() -> I18nMessageUtil.get("i18n.ssh_asset_management.3b6c"), MachineSshServer.class),
+    SYSTEM_ASSETS_MACHINE_FTP(() -> I18nMessageUtil.get("i18n.ftp_asset_management.c6a5"), MachineFtpServer.class),
     SYSTEM_ASSETS_MACHINE_DOCKER(() -> I18nMessageUtil.get("i18n.docker_asset_management.96d9"), MachineDockerServer.class),
     SYSTEM_ASSETS_GLOBAL_SCRIPT(() -> I18nMessageUtil.get("i18n.script_library.aed1"), ScriptLibraryServer.class),
     SYSTEM_CONFIG(() -> I18nMessageUtil.get("i18n.server_system_config.3181")),

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

@@ -0,0 +1,255 @@
+/*
+ * 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 cn.hutool.extra.ftp.Ftp;
+import com.jcraft.jsch.JSch;
+import java.io.File;
+import java.io.FileInputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.nio.file.Files;
+import java.util.Set;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.function.Consumer;
+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 getMachineFtpModel(FtpModel ftpModel) {
+        MachineFtpModel ftpModel1 = machineFtpServer.getByKey(ftpModel.getMachineFtpId(), false);
+        Assert.notNull(ftpModel1, I18nMessageUtil.get("i18n.no_matching_asset_ftp.d420"));
+        return ftpModel1;
+    }
+
+
+    /**
+     * 将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 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 getByMachineFtpId(String id) {
+        FtpModel model = new FtpModel();
+        model.setMachineFtpId(id);
+        return queryByBean(model);
+    }
+
+    /**
+     * FTP 上传文件(带进度)
+     *
+     * @param ftp              hutool 的 ftp 对象
+     * @param localFile        本地文件
+     * @param remotePath       远程文件完整路径
+     * @param logRecorder      日志记录器
+     * @param progressRatio    进度输出频率,例如 5 表示每 5% 输出一次
+     */
+    public void uploadWithProgress(Ftp ftp, File localFile, String remotePath,
+                                          LogRecorder logRecorder, int progressRatio) throws IOException {
+        long total = localFile.length();
+        Set<Integer> progressRangeList = ConcurrentHashMap.newKeySet((int) (100f / progressRatio));
+
+        try (InputStream in = Files.newInputStream(localFile.toPath())) {
+            ProgressInputStream progressInputStream = new ProgressInputStream(in, total, now -> {
+                double percent = Math.floor((double) now / total * 100);
+                int range = (int) (percent / progressRatio);
+                if (progressRangeList.add(range)) {
+                    logRecorder.system(I18nMessageUtil.get("i18n.upload_progress_with_units.44ad"),
+                        localFile.getName(),
+                        FileUtil.readableFileSize(now),
+                        FileUtil.readableFileSize(total),
+                        String.format("%.0f%%", percent)
+                    );
+                }
+            });
+
+             remotePath = remotePath + StrUtil.SLASH + localFile.getName();
+            ftp.getClient().storeFile(remotePath, progressInputStream);
+        }
+    }
+
+    /**
+     * 包装带进度的 InputStream
+     */
+    private static class ProgressInputStream extends InputStream {
+        private final InputStream in;
+        private final long totalSize;
+        private long readSize = 0;
+        private final Consumer<Long> onProgress;
+
+        public ProgressInputStream(InputStream in, long totalSize, Consumer<Long> onProgress) {
+            this.in = in;
+            this.totalSize = totalSize;
+            this.onProgress = onProgress;
+        }
+
+        @Override
+        public int read() throws IOException {
+            int b = in.read();
+            if (b != -1) {
+                readSize++;
+                onProgress.accept(readSize);
+            }
+            return b;
+        }
+
+        @Override
+        public int read(byte[] b, int off, int len) throws IOException {
+            int count = in.read(b, off, len);
+            if (count > 0) {
+                readSize += count;
+                onProgress.accept(readSize);
+            }
+            return count;
+        }
+
+        @Override
+        public void close() throws IOException {
+            in.close();
+        }
+    }
+}

+ 46 - 0
modules/server/src/main/java/org/dromara/jpom/socket/handler/KeyControl.java

@@ -0,0 +1,46 @@
+package org.dromara.jpom.socket.handler;
+
+import java.util.Arrays;
+
+/**
+ * 功能键枚举
+ *
+ * @author bwcx_jzy
+ * @since 2025/6/11
+ */
+public enum KeyControl {
+    KEY_TAB((byte) 9), // TAB
+    KEY_ETX((byte) 3), // Control + C
+    KEY_ENTER((byte) 13), // Enter
+    KEY_SEARCH((byte) 18), // Control + R
+    KEY_BACK((byte) 127), // 退格键
+    KEY_DELETE(new byte[]{27, 91, 51, 126}), // DELETE键
+    KEY_LEFT(new byte[]{27, 91, 68}), // 左
+    KEY_RIGHT(new byte[]{27, 91, 67}), // 右
+    KEY_UP(new byte[]{27, 91, 65}), // 上
+    KEY_DOWN(new byte[]{27, 91, 66}), // 下
+    KEY_HOME(new byte[]{27, 91, 72}),
+    KEY_END(new byte[]{27, 91, 70}),
+    KEY_FUNCTION(new byte[]{27, 91}), //其他功能键
+    KEY_INPUT(new byte[]{-1}); // 正常输入
+
+    private final byte[] control;
+
+    KeyControl(byte... control) {
+        this.control = control;
+    }
+
+    public static KeyControl getKeyControl(byte[] bytes) {
+        for (KeyControl value : KeyControl.values()) {
+            if (Arrays.equals(value.control, bytes)) {
+                return value;
+            }
+        }
+        // 其他功能键
+        if (Arrays.equals(KEY_FUNCTION.control, Arrays.copyOf(bytes, 2))) {
+            return KEY_FUNCTION;
+        }
+        // 正常输入
+        return KEY_INPUT;
+    }
+}

+ 202 - 0
modules/server/src/main/java/org/dromara/jpom/socket/handler/KeyEventCycle.java

@@ -0,0 +1,202 @@
+package org.dromara.jpom.socket.handler;
+
+import lombok.Setter;
+import lombok.extern.slf4j.Slf4j;
+
+import java.io.ByteArrayOutputStream;
+import java.nio.charset.Charset;
+import java.util.Arrays;
+import java.util.function.Consumer;
+
+/**
+ * 控制台案件事件处理
+ *
+ * @author bwcx_jzy
+ * @since 2025/6/11
+ */
+@Slf4j
+public class KeyEventCycle {
+    // 输入缓存
+    private StringBuffer buffer = new StringBuffer();
+    // 输入后是否接收返回字符串
+    private boolean inputReceive = false;
+    // TAB 输入暂停(处理Y/N确认)
+    private boolean tabInputPause = false;
+    // 光标位置
+    private int inputSelection = 0;
+    // 搜索状态,0未开始,1开始搜索,2搜索结束
+    private int searchState = 0;
+    @Setter
+    private Charset charset;
+    private KeyControl keyControl = KeyControl.KEY_END;
+    private Consumer<String> consumer;
+
+    /**
+     * 从控制台读取输入按键进行处理
+     *
+     * @param consumer 完整命令后输入回调
+     * @param bytes    输入按键
+     */
+    public void read(Consumer<String> consumer, byte... bytes) {
+        this.consumer = consumer;
+        String str = new String(bytes, charset);
+        if (keyControl == KeyControl.KEY_TAB && tabInputPause) {
+            if (str.equalsIgnoreCase("y") || str.equalsIgnoreCase("n")) {
+                tabInputPause = false;
+                return;
+            }
+        }
+        keyControl = KeyControl.getKeyControl(bytes);
+        if ((keyControl == KeyControl.KEY_INPUT || keyControl == KeyControl.KEY_FUNCTION) && !tabInputPause) {
+            buffer.insert(inputSelection, str);
+            inputSelection += str.length();
+        } else if (keyControl == KeyControl.KEY_ENTER) {
+            // 回车,结束当前输入周期
+            if (buffer.length() > 0 && searchState != 1) {
+                consumer.accept(buffer.toString());
+            } else if (searchState == 1) {
+                // Control + R结束
+                searchState = 2;
+            }
+            // 重置周期
+            buffer = new StringBuffer();
+            inputReceive = false;
+            inputSelection = 0;
+        } else if (keyControl == KeyControl.KEY_BACK) {
+            buffer.delete(Math.max(inputSelection - 1, 0), inputSelection);
+            inputSelection = Math.max(inputSelection - 1, 0);
+        } else if (keyControl == KeyControl.KEY_DELETE) {
+            buffer.delete(inputSelection, Math.min(inputSelection + 1, buffer.length()));
+        } else if (keyControl == KeyControl.KEY_LEFT) {
+            inputSelection = Math.max(inputSelection - 1, 0);
+        } else if (keyControl == KeyControl.KEY_RIGHT) {
+            inputSelection = Math.min(inputSelection + 1, buffer.length());
+        } else if (keyControl == KeyControl.KEY_HOME) {
+            inputSelection = 0;
+        } else if (keyControl == KeyControl.KEY_END) {
+            inputSelection = buffer.length();
+        } else if (keyControl == KeyControl.KEY_TAB) {
+            inputReceive = true;
+        } else if (keyControl == KeyControl.KEY_UP || keyControl == KeyControl.KEY_DOWN) {
+            // 清空命令缓冲
+            inputSelection = 0;
+            inputReceive = true;
+        } else if (keyControl == KeyControl.KEY_ETX) {
+            buffer = new StringBuffer();
+            inputSelection = 0;
+        } else if (keyControl == KeyControl.KEY_SEARCH) {
+            buffer = new StringBuffer();
+            searchState = 1;
+        }
+    }
+
+    /**
+     * 从SSH服务端接收字节
+     *
+     * @param bytes 字节
+     */
+    public void receive(byte... bytes) {
+        if (searchState == 2) {
+            // 处理搜索命令结束后,接收到ssh服务器返回的完整命令
+            int index = indexOf(bytes, new byte[]{27, 91, 75});
+            if (index > -1) {
+                bytes = Arrays.copyOf(bytes, index);
+            }
+            String str = new String(bytes, charset).split("# ")[1];
+            consumer.accept(str.trim());
+            searchState = 0;
+            return;
+        }
+        if (inputReceive) {
+            String str = new String(bytes, charset);
+            if (keyControl == KeyControl.KEY_UP || keyControl == KeyControl.KEY_DOWN) {
+                // 上下键只有第一条是正常的,后面的都是根据第一条进行退格删除再补充的。
+                // 8,8,8,99,100,32,47,112,114,50,111,99,47,
+                try {
+                    try (ByteArrayOutputStream outputStream = new ByteArrayOutputStream()) {
+                        for (byte aByte : bytes) {
+                            if (aByte == 8) {
+                                // 首位是退格键,就执行删除末尾值
+                                buffer.deleteCharAt(Math.max(buffer.length() - 1, 0));
+                            } else if (aByte == 27) {
+                                // 遇到【逃离/取消】就跳出循环
+                                break;
+                            } else if (aByte != 0) {
+                                outputStream.write(aByte);
+                            }
+                        }
+                        buffer.append(new String(outputStream.toByteArray(), charset));
+                    }
+                    inputSelection = buffer.length();
+                } catch (Exception e) {
+                    log.error("", e);
+                }
+                return;
+            } else {
+                if (keyControl == KeyControl.KEY_TAB) {
+                    if (bytes[0] == 7) {
+                        // 接收到终端响铃,就删除响铃
+                        bytes = Arrays.copyOfRange(bytes, 1, bytes.length);
+                    }
+                    if (Arrays.equals(new byte[]{13, 10}, bytes)) {
+                        inputReceive = false;
+                        return;
+                    }
+                    // tab下文件很多
+                    if (str.contains("y or n")) {
+                        tabInputPause = true;
+                        inputReceive = false;
+                        return;
+                    }
+                    // cat 'hello word.txt'
+                    // cat hello\ word.txt
+                    if (str.split(" ").length > 1 && (!str.contains("'") && !str.contains("\\"))) {
+                        inputReceive = false;
+                        return;
+                    }
+                }
+                // 非上下键输入输入中,如果接受到数据就执行插入数据,根据当前光标位置执行插入
+                // 存在退格,就从光标位置开始删除
+                int backCount = 0;
+                for (byte aByte : bytes) {
+                    if (aByte == 8) {
+                        buffer.deleteCharAt(inputSelection - 1);
+                        backCount++;
+                    }
+                }
+                str = new String(Arrays.copyOfRange(bytes, 0, bytes.length - backCount), charset);
+                // #https://gitee.com/dromara/Jpom/issues/ICA57K
+                buffer.insert(inputSelection - 1, str);
+                inputSelection += str.length();
+            }
+        }
+        inputReceive = false;
+    }
+
+    /**
+     * 查找指定字节数组在原始字节数组中的位置
+     *
+     * @param originalArray   原始字节数组
+     * @param byteArrayToFind 要查找的字节数组
+     * @return 找到的位置索引,如果找不到返回 -1
+     */
+    private static int indexOf(byte[] originalArray, byte[] byteArrayToFind) {
+        // 遍历原始字节数组,查找匹配的起始位置
+        for (int i = 0; i <= originalArray.length - byteArrayToFind.length; i++) {
+            boolean match = true;
+            for (int j = 0; j < byteArrayToFind.length; j++) {
+                if (originalArray[i + j] != byteArrayToFind[j]) {
+                    match = false;
+                    break;
+                }
+            }
+            if (match) {
+                return i;
+            }
+        }
+        return -1;
+    }
+
+}
+
+

+ 5 - 231
modules/server/src/main/java/org/dromara/jpom/socket/handler/SshHandler.java

@@ -23,7 +23,6 @@ import com.alibaba.fastjson2.JSONValidator;
 import com.jcraft.jsch.ChannelShell;
 import com.jcraft.jsch.JSchException;
 import com.jcraft.jsch.Session;
-import lombok.Setter;
 import lombok.extern.slf4j.Slf4j;
 import org.dromara.jpom.common.i18n.I18nMessageUtil;
 import org.dromara.jpom.common.i18n.I18nThreadUtil;
@@ -42,7 +41,6 @@ import org.springframework.http.HttpHeaders;
 import org.springframework.web.socket.TextMessage;
 import org.springframework.web.socket.WebSocketSession;
 
-import java.io.ByteArrayOutputStream;
 import java.io.IOException;
 import java.io.InputStream;
 import java.io.OutputStream;
@@ -52,7 +50,6 @@ import java.util.Collections;
 import java.util.List;
 import java.util.Map;
 import java.util.concurrent.ConcurrentHashMap;
-import java.util.function.Consumer;
 
 /**
  * ssh 处理2
@@ -304,7 +301,11 @@ public class SshHandler extends BaseTerminalHandler {
                 //如果没有数据来,线程会一直阻塞在这个地方等待数据。
                 while ((i = inputStream.read(buffer)) != NioUtil.EOF) {
                     byte[] tempBytes = Arrays.copyOfRange(buffer, 0, i);
-                    keyEventCycle.receive(tempBytes);
+                    try {
+                        keyEventCycle.receive(tempBytes);
+                    } catch (Exception e) {
+                        log.warn("keyEventCycle.receive", e);
+                    }
                     sendBinary(session, new String(tempBytes, machineSshModel.charset()));
                 }
             } catch (Exception e) {
@@ -334,231 +335,4 @@ public class SshHandler extends BaseTerminalHandler {
         HANDLER_ITEM_CONCURRENT_HASH_MAP.remove(session.getId());
         SocketSessionUtil.close(session);
     }
-
-    /**
-     * 控制台案件事件处理
-     */
-    public static class KeyEventCycle {
-
-        // 输入缓存
-        private StringBuffer buffer = new StringBuffer();
-        // 输入后是否接收返回字符串
-        private boolean inputReceive = false;
-        // TAB 输入暂停(处理Y/N确认)
-        private boolean tabInputPause = false;
-        // 光标位置
-        private int inputSelection = 0;
-        // 搜索状态,0未开始,1开始搜索,2搜索结束
-        private int searchState = 0;
-        @Setter
-        private Charset charset;
-        private KeyControl keyControl = KeyControl.KEY_END;
-        private Consumer<String> consumer;
-
-        /**
-         * 从控制台读取输入按键进行处理
-         *
-         * @param consumer 完整命令后输入回调
-         * @param bytes    输入按键
-         */
-        public void read(Consumer<String> consumer, byte... bytes) {
-            this.consumer = consumer;
-            String str = new String(bytes, charset);
-            if (keyControl == KeyControl.KEY_TAB && tabInputPause) {
-                if (str.equalsIgnoreCase("y") || str.equalsIgnoreCase("n")) {
-                    tabInputPause = false;
-                    return;
-                }
-            }
-            keyControl = KeyControl.getKeyControl(bytes);
-            if ((keyControl == KeyControl.KEY_INPUT || keyControl == KeyControl.KEY_FUNCTION) && !tabInputPause) {
-                buffer.insert(inputSelection, str);
-                inputSelection += str.length();
-            } else if (keyControl == KeyControl.KEY_ENTER) {
-                // 回车,结束当前输入周期
-                if (buffer.length() > 0 && searchState != 1) {
-                    consumer.accept(buffer.toString());
-                } else if (searchState == 1) {
-                    // Control + R结束
-                    searchState = 2;
-                }
-                // 重置周期
-                buffer = new StringBuffer();
-                inputReceive = false;
-                inputSelection = 0;
-            } else if (keyControl == KeyControl.KEY_BACK) {
-                buffer.delete(Math.max(inputSelection - 1, 0), inputSelection);
-                inputSelection = Math.max(inputSelection - 1, 0);
-            } else if (keyControl == KeyControl.KEY_DELETE) {
-                buffer.delete(inputSelection, Math.min(inputSelection + 1, buffer.length()));
-            } else if (keyControl == KeyControl.KEY_LEFT) {
-                inputSelection = Math.max(inputSelection - 1, 0);
-            } else if (keyControl == KeyControl.KEY_RIGHT) {
-                inputSelection = Math.min(inputSelection + 1, buffer.length());
-            } else if (keyControl == KeyControl.KEY_HOME) {
-                inputSelection = 0;
-            } else if (keyControl == KeyControl.KEY_END) {
-                inputSelection = buffer.length();
-            } else if (keyControl == KeyControl.KEY_TAB) {
-                inputReceive = true;
-            } else if (keyControl == KeyControl.KEY_UP || keyControl == KeyControl.KEY_DOWN) {
-                // 清空命令缓冲
-                inputSelection = 0;
-                inputReceive = true;
-            } else if (keyControl == KeyControl.KEY_ETX) {
-                buffer = new StringBuffer();
-                inputSelection = 0;
-            } else if (keyControl == KeyControl.KEY_SEARCH) {
-                buffer = new StringBuffer();
-                searchState = 1;
-            }
-        }
-
-        /**
-         * 从SSH服务端接收字节
-         *
-         * @param bytes 字节
-         */
-        public void receive(byte... bytes) {
-            if (searchState == 2) {
-                // 处理搜索命令结束后,接收到ssh服务器返回的完整命令
-                int index = indexOf(bytes, new byte[]{27, 91, 75});
-                if (index > -1) {
-                    bytes = Arrays.copyOf(bytes, index);
-                }
-                String str = new String(bytes, charset).split("# ")[1];
-                consumer.accept(str.trim());
-                searchState = 0;
-                return;
-            }
-            if (inputReceive) {
-                String str = new String(bytes, charset);
-                if (keyControl == KeyControl.KEY_UP || keyControl == KeyControl.KEY_DOWN) {
-                    // 上下键只有第一条是正常的,后面的都是根据第一条进行退格删除再补充的。
-                    // 8,8,8,99,100,32,47,112,114,50,111,99,47,
-                    try {
-                        try (ByteArrayOutputStream outputStream = new ByteArrayOutputStream()) {
-                            for (byte aByte : bytes) {
-                                if (aByte == 8) {
-                                    // 首位是退格键,就执行删除末尾值
-                                    buffer.deleteCharAt(Math.max(buffer.length() - 1, 0));
-                                } else if (aByte == 27) {
-                                    // 遇到【逃离/取消】就跳出循环
-                                    break;
-                                } else if (aByte != 0) {
-                                    outputStream.write(aByte);
-                                }
-                            }
-                            buffer.append(new String(outputStream.toByteArray(), charset));
-                        }
-                        inputSelection = buffer.length();
-                    } catch (Exception e) {
-                        log.error("", e);
-                    }
-                    return;
-                } else {
-                    if (keyControl == KeyControl.KEY_TAB) {
-                        if (bytes[0] == 7) {
-                            // 接收到终端响铃,就删除响铃
-                            bytes = Arrays.copyOfRange(bytes, 1, bytes.length);
-                        }
-                        if (Arrays.equals(new byte[]{13, 10}, bytes)) {
-                            inputReceive = false;
-                            return;
-                        }
-                        // tab下文件很多
-                        if (str.contains("y or n")) {
-                            tabInputPause = true;
-                            inputReceive = false;
-                            return;
-                        }
-                        // cat 'hello word.txt'
-                        // cat hello\ word.txt
-                        if (str.split(" ").length > 1 && (!str.contains("'") && !str.contains("\\"))) {
-                            inputReceive = false;
-                            return;
-                        }
-                    }
-                    // 非上下键输入输入中,如果接受到数据就执行插入数据,根据当前光标位置执行插入
-                    // 存在退格,就从光标位置开始删除
-                    int backCount = 0;
-                    for (byte aByte : bytes) {
-                        if (aByte == 8) {
-                            buffer.deleteCharAt(inputSelection - 1);
-                            backCount++;
-                        }
-                    }
-                    str = new String(Arrays.copyOfRange(bytes, 0, bytes.length - backCount), charset);
-                    buffer.insert(inputSelection, str);
-                    inputSelection += str.length();
-                }
-            }
-            inputReceive = false;
-        }
-
-        /**
-         * 查找指定字节数组在原始字节数组中的位置
-         *
-         * @param originalArray 原始字节数组
-         * @param byteArrayToFind 要查找的字节数组
-         * @return 找到的位置索引,如果找不到返回 -1
-         */
-        private static int indexOf(byte[] originalArray, byte[] byteArrayToFind) {
-            // 遍历原始字节数组,查找匹配的起始位置
-            for (int i = 0; i <= originalArray.length - byteArrayToFind.length; i++) {
-                boolean match = true;
-                for (int j = 0; j < byteArrayToFind.length; j++) {
-                    if (originalArray[i + j] != byteArrayToFind[j]) {
-                        match = false;
-                        break;
-                    }
-                }
-                if (match) {
-                    return i;
-                }
-            }
-            return -1;
-        }
-
-    }
-
-    /**
-     * 功能键枚举
-     */
-    public enum KeyControl {
-        KEY_TAB((byte) 9), // TAB
-        KEY_ETX((byte) 3), // Control + C
-        KEY_ENTER((byte) 13), // Enter
-        KEY_SEARCH((byte) 18), // Control + R
-        KEY_BACK((byte) 127), // 退格键
-        KEY_DELETE(new byte[]{27, 91, 51, 126}), // DELETE键
-        KEY_LEFT(new byte[]{27, 91, 68}), // 左
-        KEY_RIGHT(new byte[]{27, 91, 67}), // 右
-        KEY_UP(new byte[]{27, 91, 65}), // 上
-        KEY_DOWN(new byte[]{27, 91, 66}), // 下
-        KEY_HOME(new byte[]{27, 91, 72}),
-        KEY_END(new byte[]{27, 91, 70}),
-        KEY_FUNCTION(new byte[]{27, 91}), //其他功能键
-        KEY_INPUT(new byte[]{-1}); // 正常输入
-
-        private final byte[] control;
-
-        KeyControl(byte... control) {
-            this.control = control;
-        }
-
-        public static KeyControl getKeyControl(byte[] bytes) {
-            for (KeyControl value : KeyControl.values()) {
-                if (Arrays.equals(value.control, bytes)) {
-                    return value;
-                }
-            }
-            // 其他功能键
-            if (Arrays.equals(KEY_FUNCTION.control, Arrays.copyOf(bytes, 2))) {
-                return KEY_FUNCTION;
-            }
-            // 正常输入
-            return KEY_INPUT;
-        }
-    }
 }

+ 8 - 1
modules/server/src/main/resources/application.yml

@@ -129,6 +129,13 @@ jpom:
       monitor-cron: 0 0/1 * * * ?
       disable-monitor-group-name:
         - 禁用监控
+    # ftp 资产
+    ftp:
+      # 监控频率
+      monitor-cron: 0 0/1 * * * ?
+      # 指定分组不启用监控功能(如果想禁用所有配置 * 即可)
+      disable-monitor-group-name:
+        - 禁用监控
     docker:
       monitor-cron: 0 0/1 * * * ?
 server:
@@ -160,7 +167,7 @@ spring:
     #    active: mysql
     #    active: mariadb
     #    active: postgresql
-    active: dameng
+    #    active: dameng
 
   web:
     resources:

+ 7 - 0
modules/server/src/main/resources/config_default/application.yml

@@ -133,6 +133,13 @@ jpom:
       # 指定分组不启用监控功能(如果想禁用所有配置 * 即可)
       disable-monitor-group-name:
         - 禁用监控
+    # ftp 资产
+    ftp:
+      # 监控频率
+      monitor-cron: 0 0/1 * * * ?
+      # 指定分组不启用监控功能(如果想禁用所有配置 * 即可)
+      disable-monitor-group-name:
+        - 禁用监控
     # docker 资产
     docker:
       # 监控频率

+ 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管理",

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

@@ -49,6 +49,7 @@ MACHINE_NODE_STAT_LOG,networkDelay,Integer,,0,false,false,网络耗时,
 MACHINE_NODE_STAT_LOG,monitorTime,Long,,,true,false,监控的时间,
 MACHINE_NODE_STAT_LOG,netTxBytes,Long,,,false,false,"每秒发送的KB数,rxkB/s",
 MACHINE_NODE_STAT_LOG,netRxBytes,Long,,,false,false,"每秒接收的KB数,rxkB/s",
+
 MACHINE_SSH_INFO,id,String,50,,true,true,id,ssh信息表
 MACHINE_SSH_INFO,createTimeMillis,Long,,,false,false,数据创建时间,
 MACHINE_SSH_INFO,modifyTimeMillis,Long,,,false,false,数据修改时间,
@@ -65,6 +66,7 @@ MACHINE_SSH_INFO,connectType,String,10,,false,false,连接方式,
 MACHINE_SSH_INFO,timeout,Integer,,0,false,false,节点超时时间,
 MACHINE_SSH_INFO,status,TINYINT,,,true,false,状态{0,无法连接,1 正常},
 MACHINE_SSH_INFO,statusMsg,TEXT,,,false,false,状态消息,
+
 MACHINE_DOCKER_INFO,id,String,50,,true,true,id,机器DOCKER信息
 MACHINE_DOCKER_INFO,createTimeMillis,Long,,,false,false,数据创建时间,
 MACHINE_DOCKER_INFO,modifyTimeMillis,Long,,,false,false,数据修改时间,

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

@@ -40,6 +40,25 @@ 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,ftp信息表
+MACHINE_FTP_INFO,createTimeMillis,Long,,,false,false,数据创建时间,
+MACHINE_FTP_INFO,modifyTimeMillis,Long,,,false,false,数据修改时间,
+MACHINE_FTP_INFO,modifyUser,String,50,,false,false,修改人,
+MACHINE_FTP_INFO,name,String,50,,false,false,名称,
+MACHINE_FTP_INFO,groupName,String,50,,true,false,分组名称,
+MACHINE_FTP_INFO,host,String,100,,true,false,ssh host IP,
+MACHINE_FTP_INFO,port,Integer,,,true,false,端口,
+MACHINE_FTP_INFO,user,String,100,,true,false,用户,
+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 正常},
+MACHINE_FTP_INFO,statusMsg,TEXT,,,false,false,状态消息,
+
 FILE_RELEASE_TASK_TEMPLATE,id,String,50,,true,true,id,文件发布任务记录
 FILE_RELEASE_TASK_TEMPLATE,createTimeMillis,Long,,,true,false,数据创建时间,
 FILE_RELEASE_TASK_TEMPLATE,modifyTimeMillis,Long,,,false,false,数据修改时间,
@@ -49,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,
+
+

+ 725 - 0
modules/sub-plugin/ssh-jsch/src/test/java/SSHCommandRecorder.java

@@ -0,0 +1,725 @@
+/**
+ * @author bwcx_jzy
+ * @since 2025/6/11
+ */
+import com.jcraft.jsch.*;
+import java.io.*;
+import java.nio.charset.StandardCharsets;
+import java.util.*;
+import java.util.concurrent.*;
+import java.util.regex.Pattern;
+
+/**
+ * SSH终端命令记录器 - 重构版
+ * 通过双向流拦截和智能解析准确记录用户执行的所有命令
+ */
+public class SSHCommandRecorder {
+
+    // ANSI转义序列模式
+    private static final Pattern ANSI_ESCAPE = Pattern.compile("\\x1B\\[[0-?]*[ -/]*[@-~]");
+
+    // 命令提示符模式(支持多种shell)
+    private static final Pattern PROMPT_PATTERN = Pattern.compile(
+        ".*?[\\$#>%]\\s*$|.*?\\w+[@:].*?[\\$#>%]\\s*$|.*?\\]\\s*[\\$#>%]\\s*$"
+    );
+
+    // 会话相关
+    private Session session;
+    private ChannelShell channel;
+    private InputStream channelInput;
+    private OutputStream channelOutput;
+
+    // 数据处理
+    private final BlockingQueue<String> inputQueue = new LinkedBlockingQueue<>();
+    private final BlockingQueue<String> outputQueue = new LinkedBlockingQueue<>();
+    private final List<CommandRecord> commandHistory = Collections.synchronizedList(new ArrayList<>());
+
+    // 状态管理
+    private volatile boolean recording = false;
+    private final TerminalState terminalState = new TerminalState();
+
+    // 线程池
+    private final ExecutorService executorService = Executors.newFixedThreadPool(4);
+
+    /**
+     * 命令记录实体
+     */
+    public static class CommandRecord {
+        private final String command;
+        private final String user;
+        private final String host;
+        private final long timestamp;
+        private final String sessionId;
+        private String output;
+        private CommandType type;
+        private long duration;
+
+        public enum CommandType {
+            TYPED,          // 直接输入
+            HISTORY_UP,     // 上键选择
+            HISTORY_DOWN,   // 下键选择
+            SEARCH,         // 搜索选择
+            TAB_COMPLETE    // Tab补全
+        }
+
+        public CommandRecord(String command, String user, String host, String sessionId) {
+            this.command = command.trim();
+            this.user = user;
+            this.host = host;
+            this.sessionId = sessionId;
+            this.timestamp = System.currentTimeMillis();
+            this.type = CommandType.TYPED;
+        }
+
+        // Getters and Setters
+        public String getCommand() { return command; }
+        public String getUser() { return user; }
+        public String getHost() { return host; }
+        public long getTimestamp() { return timestamp; }
+        public String getSessionId() { return sessionId; }
+        public String getOutput() { return output; }
+        public CommandType getType() { return type; }
+        public long getDuration() { return duration; }
+
+        public void setOutput(String output) { this.output = output; }
+        public void setType(CommandType type) { this.type = type; }
+        public void setDuration(long duration) { this.duration = duration; }
+
+        @Override
+        public String toString() {
+            return String.format("[%s] %s@%s [%s] $ %s",
+                new Date(timestamp), user, host, type, command);
+        }
+    }
+
+    /**
+     * 终端状态跟踪
+     */
+    private static class TerminalState {
+        private final StringBuilder currentLine = new StringBuilder();
+        private final StringBuilder commandBuffer = new StringBuilder();
+        private String lastPrompt = "";
+        private String currentCommand = "";
+        private boolean inCommand = false;
+        private boolean waitingForOutput = false;
+        private long commandStartTime = 0;
+        private int cursorPosition = 0;
+
+        // 特殊键检测
+        private boolean upKeyPressed = false;
+        private boolean downKeyPressed = false;
+        private boolean ctrlRPressed = false;
+        private boolean tabPressed = false;
+
+        public synchronized void reset() {
+            currentLine.setLength(0);
+            commandBuffer.setLength(0);
+            currentCommand = "";
+            inCommand = false;
+            waitingForOutput = false;
+            commandStartTime = 0;
+            resetKeyStates();
+        }
+
+        public synchronized void resetKeyStates() {
+            upKeyPressed = false;
+            downKeyPressed = false;
+            ctrlRPressed = false;
+            tabPressed = false;
+        }
+    }
+
+    /**
+     * 输入流拦截器
+     */
+    private class InputStreamInterceptor extends InputStream {
+        private final InputStream originalInput;
+        private final ByteArrayOutputStream buffer = new ByteArrayOutputStream();
+
+        public InputStreamInterceptor(InputStream originalInput) {
+            this.originalInput = originalInput;
+        }
+
+        @Override
+        public int read() throws IOException {
+            int data = originalInput.read();
+            if (data != -1) {
+                buffer.write(data);
+                processInputByte((byte) data);
+            }
+            return data;
+        }
+
+        @Override
+        public int read(byte[] b, int off, int len) throws IOException {
+            int bytesRead = originalInput.read(b, off, len);
+            if (bytesRead > 0) {
+                buffer.write(b, off, bytesRead);
+                processInputBytes(b, off, bytesRead);
+            }
+            return bytesRead;
+        }
+
+        private void processInputByte(byte b) {
+            processInputBytes(new byte[]{b}, 0, 1);
+        }
+
+        private void processInputBytes(byte[] bytes, int offset, int length) {
+            try {
+                String input = new String(bytes, offset, length, StandardCharsets.UTF_8);
+                if (!input.isEmpty()) {
+                    inputQueue.offer(input);
+                }
+            } catch (Exception e) {
+                System.err.println("Error processing input: " + e.getMessage());
+            }
+        }
+    }
+
+    /**
+     * 输出流拦截器
+     */
+    private class OutputStreamInterceptor extends OutputStream {
+        private final OutputStream originalOutput;
+        private final ByteArrayOutputStream buffer = new ByteArrayOutputStream();
+
+        public OutputStreamInterceptor(OutputStream originalOutput) {
+            this.originalOutput = originalOutput;
+        }
+
+        @Override
+        public void write(int b) throws IOException {
+            originalOutput.write(b);
+            buffer.write(b);
+            processOutputByte((byte) b);
+        }
+
+        @Override
+        public void write(byte[] b, int off, int len) throws IOException {
+            originalOutput.write(b, off, len);
+            buffer.write(b, off, len);
+            processOutputBytes(b, off, len);
+        }
+
+        @Override
+        public void flush() throws IOException {
+            originalOutput.flush();
+        }
+
+        private void processOutputByte(byte b) {
+            processOutputBytes(new byte[]{b}, 0, 1);
+        }
+
+        private void processOutputBytes(byte[] bytes, int offset, int length) {
+            try {
+                String output = new String(bytes, offset, length, StandardCharsets.UTF_8);
+                if (!output.isEmpty()) {
+                    outputQueue.offer(output);
+                }
+            } catch (Exception e) {
+                System.err.println("Error processing output: " + e.getMessage());
+            }
+        }
+    }
+
+    /**
+     * 建立SSH连接并开始记录
+     */
+    public void connect(String host, int port, String username, String password) throws Exception {
+        JSch jsch = new JSch();
+        session = jsch.getSession(username, host, port);
+        session.setPassword(password);
+        session.setConfig("StrictHostKeyChecking", "no");
+        session.connect();
+
+        channel = (ChannelShell) session.openChannel("shell");
+
+        // 设置终端参数
+        channel.setPtyType("xterm-256color");
+        channel.setPtySize(120, 40, 960, 640);
+
+        // 创建管道流
+        PipedInputStream toChannelInput = new PipedInputStream();
+        PipedOutputStream fromUserInput = new PipedOutputStream(toChannelInput);
+
+        PipedInputStream fromChannelOutput = new PipedInputStream();
+        PipedOutputStream toUserOutput = new PipedOutputStream(fromChannelOutput);
+
+        // 设置拦截器
+        channelInput = new InputStreamInterceptor(System.in);
+        channelOutput = new OutputStreamInterceptor(System.out);
+
+        channel.setInputStream(toChannelInput);
+        channel.setOutputStream(toUserOutput);
+
+        channel.connect();
+
+        // 获取真实的输入输出流
+        InputStream realChannelOutput = channel.getInputStream();
+        OutputStream realChannelInput = channel.getOutputStream();
+
+        recording = true;
+
+        // 启动处理线程
+        startProcessingThreads(realChannelOutput, realChannelInput, fromUserInput, fromChannelOutput);
+
+        System.out.println("SSH连接已建立,开始记录命令...");
+    }
+
+    /**
+     * 启动处理线程
+     */
+    private void startProcessingThreads(InputStream channelOut, OutputStream channelIn,
+                                        OutputStream userIn, InputStream userOut) {
+
+        // 处理用户输入 -> SSH服务器
+        executorService.submit(() -> {
+            try {
+                Scanner scanner = new Scanner(System.in);
+                while (recording && scanner.hasNextLine()) {
+                    String line = scanner.nextLine();
+                    processUserInput(line);
+                    channelIn.write((line + "\r\n").getBytes(StandardCharsets.UTF_8));
+                    channelIn.flush();
+                }
+            } catch (Exception e) {
+                if (recording) {
+                    e.printStackTrace();
+                }
+            }
+        });
+
+        // 处理SSH服务器输出 -> 用户
+        executorService.submit(() -> {
+            try {
+                byte[] buffer = new byte[1024];
+                int bytesRead;
+                while (recording && (bytesRead = channelOut.read(buffer)) != -1) {
+                    String output = new String(buffer, 0, bytesRead, StandardCharsets.UTF_8);
+                    processServerOutput(output);
+                    System.out.print(output);
+                }
+            } catch (Exception e) {
+                if (recording) {
+                    e.printStackTrace();
+                }
+            }
+        });
+
+        // 输入分析线程
+        executorService.submit(this::processInputQueue);
+
+        // 输出分析线程
+        executorService.submit(this::processOutputQueue);
+    }
+
+    /**
+     * 处理用户输入
+     */
+    private void processUserInput(String input) {
+        synchronized (terminalState) {
+            // 检测特殊键序列
+            if (input.contains("\u001B[A")) { // 上键
+                terminalState.upKeyPressed = true;
+            } else if (input.contains("\u001B[B")) { // 下键
+                terminalState.downKeyPressed = true;
+            } else if (input.contains("\u0012")) { // Ctrl+R
+                terminalState.ctrlRPressed = true;
+            } else if (input.contains("\t")) { // Tab
+                terminalState.tabPressed = true;
+            }
+
+            // 处理正常命令输入
+            if (!input.trim().isEmpty() && !containsOnlyControlChars(input)) {
+                terminalState.currentCommand = input.trim();
+                terminalState.inCommand = true;
+                terminalState.commandStartTime = System.currentTimeMillis();
+                terminalState.waitingForOutput = true;
+            }
+        }
+    }
+
+    /**
+     * 处理服务器输出
+     */
+    private void processServerOutput(String output) {
+        synchronized (terminalState) {
+            String cleanOutput = cleanAnsiEscapes(output);
+            terminalState.currentLine.append(cleanOutput);
+
+            // 检测命令提示符
+            if (isPromptLine(cleanOutput)) {
+                if (terminalState.waitingForOutput && !terminalState.currentCommand.isEmpty()) {
+                    // 记录命令
+                    recordCommand(terminalState.currentCommand, determineCommandType());
+                    terminalState.reset();
+                }
+
+                // 提取新的提示符
+                String[] lines = cleanOutput.split("\\r?\\n");
+                for (String line : lines) {
+                    if (isPromptLine(line.trim())) {
+                        terminalState.lastPrompt = line.trim();
+                        break;
+                    }
+                }
+            }
+        }
+    }
+
+    /**
+     * 处理输入队列
+     */
+    private void processInputQueue() {
+        while (recording) {
+            try {
+                String input = inputQueue.poll(100, TimeUnit.MILLISECONDS);
+                if (input != null) {
+                    analyzeInput(input);
+                }
+            } catch (InterruptedException e) {
+                Thread.currentThread().interrupt();
+                break;
+            }
+        }
+    }
+
+    /**
+     * 处理输出队列
+     */
+    private void processOutputQueue() {
+        while (recording) {
+            try {
+                String output = outputQueue.poll(100, TimeUnit.MILLISECONDS);
+                if (output != null) {
+                    analyzeOutput(output);
+                }
+            } catch (InterruptedException e) {
+                Thread.currentThread().interrupt();
+                break;
+            }
+        }
+    }
+
+    /**
+     * 分析输入数据
+     */
+    private void analyzeInput(String input) {
+        // 检测控制序列
+        if (input.contains("\u001B[A")) {
+            terminalState.upKeyPressed = true;
+        } else if (input.contains("\u001B[B")) {
+            terminalState.downKeyPressed = true;
+        } else if (input.contains("\u0012")) {
+            terminalState.ctrlRPressed = true;
+        }
+
+        // 检测回车键(命令执行)
+        if (input.contains("\r") || input.contains("\n")) {
+            synchronized (terminalState) {
+                if (terminalState.inCommand) {
+                    terminalState.waitingForOutput = true;
+                }
+            }
+        }
+    }
+
+    /**
+     * 分析输出数据
+     */
+    private void analyzeOutput(String output) {
+        String cleanOutput = cleanAnsiEscapes(output);
+
+        // 检测命令回显和提示符
+        String[] lines = cleanOutput.split("\\r?\\n");
+        for (String line : lines) {
+            String trimmedLine = line.trim();
+
+            if (isPromptLine(trimmedLine)) {
+                synchronized (terminalState) {
+                    if (terminalState.waitingForOutput && !terminalState.currentCommand.isEmpty()) {
+                        recordCommand(terminalState.currentCommand, determineCommandType());
+                        terminalState.reset();
+                    }
+                    terminalState.lastPrompt = trimmedLine;
+                }
+            } else if (!trimmedLine.isEmpty() && !terminalState.inCommand) {
+                // 可能是命令回显
+                synchronized (terminalState) {
+                    if (isPotentialCommand(trimmedLine)) {
+                        terminalState.currentCommand = trimmedLine;
+                        terminalState.inCommand = true;
+                        terminalState.commandStartTime = System.currentTimeMillis();
+                    }
+                }
+            }
+        }
+    }
+
+    /**
+     * 判断命令类型
+     */
+    private CommandRecord.CommandType determineCommandType() {
+        if (terminalState.upKeyPressed || terminalState.downKeyPressed) {
+            return terminalState.upKeyPressed ?
+                CommandRecord.CommandType.HISTORY_UP : CommandRecord.CommandType.HISTORY_DOWN;
+        } else if (terminalState.ctrlRPressed) {
+            return CommandRecord.CommandType.SEARCH;
+        } else if (terminalState.tabPressed) {
+            return CommandRecord.CommandType.TAB_COMPLETE;
+        } else {
+            return CommandRecord.CommandType.TYPED;
+        }
+    }
+
+    /**
+     * 记录命令
+     */
+    private void recordCommand(String command, CommandRecord.CommandType type) {
+        if (command == null || command.trim().isEmpty() || !isValidCommand(command)) {
+            return;
+        }
+
+        try {
+            String username = session.getUserName();
+            String hostname = session.getHost();
+            String sessionId = Integer.toHexString(session.hashCode());
+
+            CommandRecord record = new CommandRecord(command, username, hostname, sessionId);
+            record.setType(type);
+
+            if (terminalState.commandStartTime > 0) {
+                record.setDuration(System.currentTimeMillis() - terminalState.commandStartTime);
+            }
+
+            commandHistory.add(record);
+
+            // 记录到控制台和日志
+            System.out.println("\n[AUDIT] " + record);
+
+            // 异步写入文件或数据库
+            executorService.submit(() -> writeToAuditLog(record));
+
+        } catch (Exception e) {
+            System.err.println("Error recording command: " + e.getMessage());
+        }
+    }
+
+    /**
+     * 写入审计日志
+     */
+    private void writeToAuditLog(CommandRecord record) {
+        try {
+            String logFile = "ssh_audit_" + new java.text.SimpleDateFormat("yyyy-MM-dd").format(new Date()) + ".log";
+            try (PrintWriter writer = new PrintWriter(new FileWriter(logFile, true))) {
+                writer.printf("%d|%s|%s|%s|%s|%s|%d%n",
+                    record.getTimestamp(),
+                    record.getUser(),
+                    record.getHost(),
+                    record.getSessionId(),
+                    record.getType(),
+                    record.getCommand().replace("|", "\\|"),
+                    record.getDuration());
+            }
+        } catch (IOException e) {
+            System.err.println("Error writing to audit log: " + e.getMessage());
+        }
+    }
+
+    /**
+     * 清理ANSI转义序列
+     */
+    private String cleanAnsiEscapes(String input) {
+        if (input == null) return "";
+        return ANSI_ESCAPE.matcher(input).replaceAll("");
+    }
+
+    /**
+     * 检测是否为命令提示符行
+     */
+    private boolean isPromptLine(String line) {
+        return PROMPT_PATTERN.matcher(line).matches() ||
+            line.endsWith("$ ") || line.endsWith("# ") || line.endsWith("> ") ||
+            line.matches(".*?\\w+[@:].*?[\\$#>].*");
+    }
+
+    /**
+     * 检测是否为潜在命令
+     */
+    private boolean isPotentialCommand(String line) {
+        return line.length() > 0 &&
+            line.length() < 500 &&
+            !line.matches("^\\s*$") &&
+            !line.startsWith("Welcome") &&
+            !line.startsWith("Last login") &&
+            !line.matches(".*?\\d{4}-\\d{2}-\\d{2}.*?");
+    }
+
+    /**
+     * 验证是否为有效命令
+     */
+    private boolean isValidCommand(String command) {
+        if (command == null || command.trim().isEmpty()) {
+            return false;
+        }
+
+        String trimmed = command.trim();
+        return trimmed.length() > 0 &&
+            trimmed.length() < 1000 &&
+            !trimmed.matches("^\\s*$") &&
+            !trimmed.matches("^\\d+$") &&
+            !containsOnlyControlChars(trimmed);
+    }
+
+    /**
+     * 检查是否只包含控制字符
+     */
+    private boolean containsOnlyControlChars(String str) {
+        return str.chars().allMatch(ch -> ch < 32 || ch == 127);
+    }
+
+    /**
+     * 发送命令(用于程序化调用)
+     */
+    public void sendCommand(String command) throws Exception {
+        if (channel != null && channel.isConnected()) {
+            OutputStream out = channel.getOutputStream();
+            out.write((command + "\r\n").getBytes(StandardCharsets.UTF_8));
+            out.flush();
+        }
+    }
+
+    /**
+     * 获取命令历史记录
+     */
+    public List<CommandRecord> getCommandHistory() {
+        synchronized (commandHistory) {
+            return new ArrayList<>(commandHistory);
+        }
+    }
+
+    /**
+     * 根据条件查询命令
+     */
+    public List<CommandRecord> queryCommands(String userFilter, long startTime, long endTime,
+                                             CommandRecord.CommandType typeFilter) {
+        synchronized (commandHistory) {
+            return commandHistory.stream()
+                .filter(cmd -> userFilter == null || userFilter.equals(cmd.getUser()))
+                .filter(cmd -> cmd.getTimestamp() >= startTime && cmd.getTimestamp() <= endTime)
+                .filter(cmd -> typeFilter == null || typeFilter.equals(cmd.getType()))
+                .collect(ArrayList::new, (list, item) -> list.add(item), (list1, list2) -> list1.addAll(list2));
+        }
+    }
+
+    /**
+     * 导出审计报告
+     */
+    public void exportAuditReport(String filename) throws IOException {
+        try (PrintWriter writer = new PrintWriter(new FileWriter(filename))) {
+            writer.println("SSH Command Audit Report");
+            writer.println("Generated: " + new Date());
+           // writer.println("=".repeat(80));
+            writer.println();
+
+            synchronized (commandHistory) {
+                Map<String, Long> userStats = new HashMap<>();
+                Map<CommandRecord.CommandType, Long> typeStats = new HashMap<>();
+
+                for (CommandRecord record : commandHistory) {
+                    userStats.merge(record.getUser(), 1L, Long::sum);
+                    typeStats.merge(record.getType(), 1L, Long::sum);
+                }
+
+                writer.println("Statistics:");
+                writer.println("Total Commands: " + commandHistory.size());
+                writer.println("Users: " + userStats);
+                writer.println("Command Types: " + typeStats);
+                writer.println();
+
+                writer.println("Command Details:");
+               // writer.println("-".repeat(80));
+                for (CommandRecord record : commandHistory) {
+                    writer.printf("[%s] %s@%s [%s] [%dms] $ %s%n",
+                        new Date(record.getTimestamp()),
+                        record.getUser(),
+                        record.getHost(),
+                        record.getType(),
+                        record.getDuration(),
+                        record.getCommand());
+                }
+            }
+        }
+    }
+
+    /**
+     * 断开连接
+     */
+    public void disconnect() {
+        recording = false;
+
+        executorService.shutdown();
+        try {
+            if (!executorService.awaitTermination(5, TimeUnit.SECONDS)) {
+                executorService.shutdownNow();
+            }
+        } catch (InterruptedException e) {
+            executorService.shutdownNow();
+            Thread.currentThread().interrupt();
+        }
+
+        if (channel != null && channel.isConnected()) {
+            channel.disconnect();
+        }
+
+        if (session != null && session.isConnected()) {
+            session.disconnect();
+        }
+
+        System.out.println("SSH连接已断开,命令记录已停止。");
+    }
+
+    /**
+     * 使用示例和测试
+     */
+    public static void main(String[] args) {
+        SSHCommandRecorder recorder = new SSHCommandRecorder();
+
+        try {
+            // 连接到SSH服务器
+            System.out.println("正在连接SSH服务器...");
+            recorder.connect("192.168.30.29", 22, "user", "123456+.");
+
+            // 保持连接,让用户交互
+            System.out.println("请在终端中执行命令,所有命令将被记录...");
+            System.out.println("输入 'exit' 或按 Ctrl+C 结束记录");
+
+            // 等待用户操作
+            Scanner scanner = new Scanner(System.in);
+            while (scanner.hasNextLine()) {
+                String input = scanner.nextLine();
+                if ("exit".equals(input)) {
+                    break;
+                }
+            }
+
+        } catch (Exception e) {
+            e.printStackTrace();
+        } finally {
+            // 导出审计报告
+            try {
+                List<CommandRecord> history = recorder.getCommandHistory();
+                System.out.println("\n=== 命令执行历史 ===");
+                history.forEach(System.out::println);
+
+                recorder.exportAuditReport("ssh_audit_report.txt");
+                System.out.println("审计报告已导出到: ssh_audit_report.txt");
+
+            } catch (Exception e) {
+                e.printStackTrace();
+            }
+
+            recorder.disconnect();
+        }
+    }
+}

+ 444 - 0
modules/sub-plugin/ssh-jsch/src/test/java/TerminalParserRecorder.java

@@ -0,0 +1,444 @@
+/**
+ * @author bwcx_jzy
+ * @since 2025/6/11
+ */
+import com.jcraft.jsch.*;
+import java.io.*;
+import java.util.*;
+import java.util.regex.Pattern;
+import java.util.concurrent.BlockingQueue;
+import java.util.concurrent.LinkedBlockingQueue;
+
+/**
+ * 基于终端解析的轻量级命令记录器
+ * 通过解析终端输出流来识别和记录执行的命令
+ */
+public class TerminalParserRecorder {
+
+    private static final Pattern PROMPT_PATTERNS = Pattern.compile(
+        ".*?[\\$#>]\\s*$|.*?\\w+@\\w+[:\\s]+.*?[\\$#>]\\s*$"
+    );
+
+    private static final Pattern COMMAND_COMPLETION_PATTERN = Pattern.compile(
+        ".*?\\[\\d+\\]\\s*.*?|.*?\\+\\s*.*?|.*?>\\s*.*?"
+    );
+
+    private Session session;
+    private ChannelShell channel;
+    private BlockingQueue<String> outputQueue;
+    private List<ExecutedCommand> commandLog;
+    private TerminalState currentState;
+    private Thread parsingThread;
+    private volatile boolean isRecording = false;
+
+    public TerminalParserRecorder() {
+        this.outputQueue = new LinkedBlockingQueue<>();
+        this.commandLog = Collections.synchronizedList(new ArrayList<>());
+        this.currentState = new TerminalState();
+    }
+
+    /**
+     * 终端状态跟踪
+     */
+    private static class TerminalState {
+        private StringBuilder currentLine = new StringBuilder();
+        private String lastPrompt = "";
+        private String pendingCommand = "";
+        private boolean waitingForPrompt = false;
+        private int cursorPosition = 0;
+
+        public void reset() {
+            currentLine.setLength(0);
+            pendingCommand = "";
+            waitingForPrompt = false;
+            cursorPosition = 0;
+        }
+    }
+
+    /**
+     * 执行命令记录
+     */
+    public static class ExecutedCommand {
+        private final String command;
+        private final String user;
+        private final String host;
+        private final long timestamp;
+        private final String sessionId;
+        private String output;
+        private int exitCode = -1;
+
+        public ExecutedCommand(String command, String user, String host, String sessionId) {
+            this.command = command;
+            this.user = user;
+            this.host = host;
+            this.sessionId = sessionId;
+            this.timestamp = System.currentTimeMillis();
+        }
+
+        // Getters
+        public String getCommand() { return command; }
+        public String getUser() { return user; }
+        public String getHost() { return host; }
+        public long getTimestamp() { return timestamp; }
+        public String getSessionId() { return sessionId; }
+        public String getOutput() { return output; }
+        public int getExitCode() { return exitCode; }
+
+        public void setOutput(String output) { this.output = output; }
+        public void setExitCode(int exitCode) { this.exitCode = exitCode; }
+
+        @Override
+        public String toString() {
+            return String.format("[%s] %s@%s $ %s",
+                new Date(timestamp).toString(), user, host, command);
+        }
+    }
+
+    /**
+     * 连接SSH并开始记录
+     */
+    public void connect(String host, int port, String username, String password) throws Exception {
+        JSch jsch = new JSch();
+        session = jsch.getSession(username, host, port);
+        session.setPassword(password);
+        session.setConfig("StrictHostKeyChecking", "no");
+        session.connect();
+
+        channel = (ChannelShell) session.openChannel("shell");
+
+        // 设置终端类型以获得更好的解析效果
+        channel.setPtyType("vt100");
+        channel.setPtySize(80, 24, 640, 480);
+
+        // 创建输出拦截流
+        ByteArrayOutputStream baos = new ByteArrayOutputStream();
+        TeeOutputStream teeOut = new TeeOutputStream(System.out, baos);
+        channel.setOutputStream(teeOut);
+
+        channel.connect();
+
+        // 启动解析线程
+        startParsing(channel.getInputStream());
+        isRecording = true;
+    }
+
+    /**
+     * 开始解析终端输出
+     */
+    private void startParsing(InputStream inputStream) {
+        parsingThread = new Thread(() -> {
+            try (BufferedReader reader = new BufferedReader(new InputStreamReader(inputStream))) {
+                StringBuilder outputBuffer = new StringBuilder();
+                int ch;
+
+                while (isRecording && (ch = reader.read()) != -1) {
+                    char character = (char) ch;
+
+                    // 处理特殊字符
+                    if (character == '\r') {
+                        continue; // 忽略回车符
+                    } else if (character == '\n') {
+                        // 处理换行
+                        String line = outputBuffer.toString();
+                        processLine(line);
+                        outputBuffer.setLength(0);
+                    } else if (character == '\b') {
+                        // 处理退格
+                        if (outputBuffer.length() > 0) {
+                            outputBuffer.deleteCharAt(outputBuffer.length() - 1);
+                        }
+                    } else if (character == '\u001B') {
+                        // 处理ANSI转义序列
+                        String escapeSequence = readEscapeSequence(reader);
+                        processEscapeSequence(escapeSequence);
+                    } else {
+                        outputBuffer.append(character);
+                    }
+                }
+            } catch (IOException e) {
+                if (isRecording) {
+                    e.printStackTrace();
+                }
+            }
+        });
+
+        parsingThread.setDaemon(true);
+        parsingThread.start();
+    }
+
+    /**
+     * 处理每一行输出
+     */
+    private void processLine(String line) {
+        String cleanLine = cleanAnsiEscapes(line);
+
+        // 检测命令提示符
+        if (isPromptLine(cleanLine)) {
+            if (currentState.waitingForPrompt && !currentState.pendingCommand.isEmpty()) {
+                // 记录已执行的命令
+                recordCommand(currentState.pendingCommand.trim());
+                currentState.reset();
+            }
+            currentState.lastPrompt = cleanLine;
+        } else if (!cleanLine.trim().isEmpty()) {
+            // 可能是命令行
+            String potentialCommand = extractPotentialCommand(cleanLine);
+            if (potentialCommand != null) {
+                currentState.pendingCommand = potentialCommand;
+                currentState.waitingForPrompt = true;
+            }
+        }
+    }
+
+    /**
+     * 读取ANSI转义序列
+     */
+    private String readEscapeSequence(BufferedReader reader) throws IOException {
+        StringBuilder seq = new StringBuilder("\u001B");
+        int ch;
+
+        // 读取完整的转义序列
+        while ((ch = reader.read()) != -1) {
+            char c = (char) ch;
+            seq.append(c);
+
+            // 大多数转义序列以字母结束
+            if (Character.isLetter(c)) {
+                break;
+            }
+
+            // 防止无限循环
+            if (seq.length() > 20) {
+                break;
+            }
+        }
+
+        return seq.toString();
+    }
+
+    /**
+     * 处理ANSI转义序列
+     */
+    private void processEscapeSequence(String sequence) {
+        // 这里可以处理光标移动、清屏等操作
+        // 对于命令记录,主要关注的是内容而不是格式
+        if (sequence.contains("K")) {
+            // 清除行的部分内容
+            currentState.currentLine.setLength(0);
+        }
+    }
+
+    /**
+     * 清理ANSI转义字符
+     */
+    private String cleanAnsiEscapes(String input) {
+        return input.replaceAll("\\x1B\\[[0-9;]*[a-zA-Z]", "");
+    }
+
+    /**
+     * 检测是否为命令提示符行
+     */
+    private boolean isPromptLine(String line) {
+        return PROMPT_PATTERNS.matcher(line).matches() ||
+            line.endsWith("$ ") ||
+            line.endsWith("# ") ||
+            line.endsWith("> ") ||
+            line.matches(".*?\\w+@\\w+.*?[\\$#>].*");
+    }
+
+    /**
+     * 从行中提取潜在的命令
+     */
+    private String extractPotentialCommand(String line) {
+        // 移除命令提示符部分
+        String[] parts = line.split("[\\$#>]", 2);
+        if (parts.length > 1) {
+            String command = parts[1].trim();
+            if (!command.isEmpty() && isValidCommand(command)) {
+                return command;
+            }
+        }
+
+        // 如果没有找到提示符,检查是否是直接的命令
+        String trimmed = line.trim();
+        if (isValidCommand(trimmed)) {
+            return trimmed;
+        }
+
+        return null;
+    }
+
+    /**
+     * 验证是否为有效命令
+     */
+    private boolean isValidCommand(String command) {
+        if (command == null || command.isEmpty()) {
+            return false;
+        }
+
+        // 过滤掉明显不是命令的内容
+        return !command.matches(".*?\\d+\\s*$") && // 不是纯数字
+            !command.matches("^[\\s\\-=]+$") && // 不是分隔线
+            !command.startsWith("Welcome") &&   // 不是欢迎信息
+            !command.startsWith("Last login") && // 不是登录信息
+            command.length() < 1000; // 不是过长的输出
+    }
+
+    /**
+     * 记录命令
+     */
+    private void recordCommand(String command) {
+        try {
+            String username = session.getUserName();
+            String hostname = session.getHost();
+            String sessionId = Integer.toHexString(session.hashCode());
+
+            ExecutedCommand record = new ExecutedCommand(command, username, hostname, sessionId);
+            commandLog.add(record);
+
+            // 记录日志
+            System.out.println("Recorded command: " + record);
+
+            // 可以在这里添加其他处理逻辑,如写入数据库、发送审计日志等
+
+        } catch (Exception e) {
+            System.err.println("Error recording command: " + e.getMessage());
+        }
+    }
+
+    /**
+     * 发送命令
+     */
+    public void sendCommand(String command) throws Exception {
+        if (channel != null && channel.isConnected()) {
+            OutputStream out = channel.getOutputStream();
+            out.write((command + "\n").getBytes());
+            out.flush();
+        }
+    }
+
+    /**
+     * 获取命令日志
+     */
+    public List<ExecutedCommand> getCommandLog() {
+        return new ArrayList<>(commandLog);
+    }
+
+    /**
+     * 根据条件查询命令
+     */
+    public List<ExecutedCommand> queryCommands(String userFilter, long startTime, long endTime) {
+        synchronized (commandLog) {
+            return commandLog.stream()
+                .filter(cmd -> userFilter == null || userFilter.equals(cmd.getUser()))
+                .filter(cmd -> cmd.getTimestamp() >= startTime && cmd.getTimestamp() <= endTime)
+                .collect(ArrayList::new, (list, item) -> list.add(item), (list1, list2) -> list1.addAll(list2));
+        }
+    }
+
+    /**
+     * 导出命令日志
+     */
+    public void exportToFile(String filename) throws IOException {
+        try (PrintWriter writer = new PrintWriter(new FileWriter(filename))) {
+            writer.println("Timestamp,User,Host,SessionId,Command");
+            synchronized (commandLog) {
+                for (ExecutedCommand cmd : commandLog) {
+                    writer.printf("%d,%s,%s,%s,\"%s\"%n",
+                        cmd.getTimestamp(),
+                        cmd.getUser(),
+                        cmd.getHost(),
+                        cmd.getSessionId(),
+                        cmd.getCommand().replace("\"", "\"\""));
+                }
+            }
+        }
+    }
+
+    /**
+     * 断开连接
+     */
+    public void disconnect() {
+        isRecording = false;
+
+        if (parsingThread != null) {
+            parsingThread.interrupt();
+        }
+
+        if (channel != null && channel.isConnected()) {
+            channel.disconnect();
+        }
+
+        if (session != null && session.isConnected()) {
+            session.disconnect();
+        }
+    }
+
+    /**
+     * Tee输出流,同时写入两个流
+     */
+    private static class TeeOutputStream extends OutputStream {
+        private OutputStream out1;
+        private OutputStream out2;
+
+        public TeeOutputStream(OutputStream out1, OutputStream out2) {
+            this.out1 = out1;
+            this.out2 = out2;
+        }
+
+        @Override
+        public void write(int b) throws IOException {
+            out1.write(b);
+            out2.write(b);
+        }
+
+        @Override
+        public void flush() throws IOException {
+            out1.flush();
+            out2.flush();
+        }
+
+        @Override
+        public void close() throws IOException {
+            out1.close();
+            out2.close();
+        }
+    }
+
+    /**
+     * 使用示例
+     */
+    public static void main(String[] args) {
+        TerminalParserRecorder recorder = new TerminalParserRecorder();
+
+        try {
+            recorder.connect("192.168.30.29", 22, "user", "123456+.");
+
+            // 等待连接稳定
+            Thread.sleep(2000);
+
+            // 发送测试命令
+            recorder.sendCommand("ls -la");
+            Thread.sleep(3000);
+
+            recorder.sendCommand("pwd");
+            Thread.sleep(2000);
+
+            recorder.sendCommand("history | tail -5");
+            Thread.sleep(3000);
+
+            // 查看记录的命令
+            List<ExecutedCommand> commands = recorder.getCommandLog();
+            System.out.println("\n=== Command Log ===");
+            commands.forEach(System.out::println);
+
+            // 导出到文件
+            recorder.exportToFile("command_audit.csv");
+
+        } catch (Exception e) {
+            e.printStackTrace();
+        } finally {
+            recorder.disconnect();
+        }
+    }
+}

+ 2 - 1
web-vue/src/api/build-info.ts

@@ -336,7 +336,8 @@ export const releaseMethodMap = {
   2: t('i18n_31ecc0e65b'),
   3: 'SSH',
   4: t('i18n_b71a7e6aab'),
-  5: t('i18n_9136e1859a')
+  5: t('i18n_9136e1859a'),
+  6: 'FTP'
 }
 
 export const triggerBuildTypeMap = {

+ 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
+}

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

@@ -0,0 +1,76 @@
+///
+/// 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'
+  })
+}
+
+// 根据 nodeId 查询列表
+export function getFtpListAll() {
+  return axios({
+    url: '/node/ftp/list_data_all.json',
+    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
+  })
+}

+ 9 - 0
web-vue/src/api/server-script.ts

@@ -81,6 +81,15 @@ export function scriptDel(params) {
   })
 }
 
+// 批量删除执行记录
+export function scriptBatchDel(params) {
+  return axios({
+    url: '/script_log/batch_del_log',
+    method: 'post',
+    data: params
+  })
+}
+
 //执行记录 详情
 export function scriptLog(params) {
   return axios({

+ 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'}
+}

+ 2 - 2
web-vue/src/components/customTable/index.vue

@@ -91,7 +91,7 @@
                           >
                             <VerticalLeftOutlined v-if="!!item.fixed" class="custom-column-list__icon" />
                             <HolderOutlined v-else class="custom-column-list__icon" />
-                            <a-checkbox :value="item.dataIndex" :disabled="!!item.fixed">
+                            <a-checkbox :value="String(item.dataIndex)" :disabled="!!item.fixed">
                               {{ item.title }}
                             </a-checkbox>
                             <a-divider style="margin: 2px 0" />
@@ -377,7 +377,7 @@ export default defineComponent({
           storageService.setColumnConfig(
             customColumnList.value.map((item) => {
               return {
-                key: item.dataIndex,
+                key: String(item.dataIndex),
                 checked: item.checked
               } as CatchStorageType
             })

+ 29 - 0
web-vue/src/i18n/locales/en_us.json

@@ -30,6 +30,7 @@
 	"i18n_02e35447d4":"Download the bundle. If the button is not available, it means that the product file does not exist. Generally, the corresponding file is not generated by the build or the file related to the build history is deleted.",
 	"i18n_0306ea1908":"Delete Mirror",
 	"i18n_031020489f":"Current workspace The build record you triggered",
+	"i18n_03113c0f1a":"Do you really want to delete FTP?",
 	"i18n_03580275cb":"Please select the item you want to restart",
 	"i18n_0360fffb40":"And turn on this switch",
 	"i18n_036c0dc2aa":"System Cancel Distribution",
@@ -83,6 +84,7 @@
 	"i18n_080b914139":"Upload package",
 	"i18n_0836332bf6":"upgrade protocol",
 	"i18n_083b8a2ec9":"A physical node bound to multiple server levels can also generate lonely data",
+	"i18n_087c992cc0":"passive mode",
 	"i18n_08902526f1":"Skin:",
 	"i18n_0895c740a6":"swap memory usage",
 	"i18n_089a88ecee":"System time:",
@@ -136,6 +138,7 @@
 	"i18n_0ce54ecc25":"paid community",
 	"i18n_0cf4f0ba82":"Do you really want to save the current configuration? If the configuration is wrong, the service may not be started. You need to manually restore Austria!!! Please pay attention to the restart status in time after successful saving!!",
 	"i18n_0cf81d77bb":"Please fill in the warehouse address.",
+	"i18n_0d1ee51203":"system keywords",
 	"i18n_0d44f4903a":"Do you really want to release (delete) the current project?",
 	"i18n_0d467f7889":"#Whether to enable the log backup function",
 	"i18n_0d48f8e881":"Please enter the service address",
@@ -213,6 +216,7 @@
 	"i18n_143bfbc3a1":"Click to resynchronize the current workspace logical node project information",
 	"i18n_143d8d3de5":"Otherwise, all data that meets the conditions will be deleted",
 	"i18n_148484b985":"You need to configure the docker container to be managed at the server level and assigned to the current workspace",
+	"i18n_148d37218a":"FTP name",
 	"i18n_1498557b2d":"Only one menu can be expanded at a time",
 	"i18n_14a25beebb":"Every 10 seconds",
 	"i18n_14d342362f":"label",
@@ -281,6 +285,7 @@
 	"i18n_1a8f90122f":"prompt message ",
 	"i18n_1abf39bdb6":"#Cache this directory globally (multiple builds can share this cache directory)",
 	"i18n_1ad696efdc":"Build execution commands (non-blocking commands), such as: mvn clean package, npm run build. Supported variables: {'${BUILD_ID BUILD_NAME BUILD_SOURCE_FILE }'}、{'${ BUILD_NUMBER_ID}'}, .env in the warehouse directory, workspace variables",
+	"i18n_1add83f77b":"FTP name",
 	"i18n_1ae2955867":"Specify the pom file package mvn -f xxx/pom.xml clean package",
 	"i18n_1afdb4a364":"Hide scroll bar. Longitudinal scroll mode Reminder: scroll wheel, horizontal scroll mode: Shift + scroll wheel",
 	"i18n_1b03b0c1ff":"The Docker or cluster that has been assigned to the workspace is not directly deleted. You need to delete the assets Docker or cluster one by one after the assigned workspaces are deleted.",
@@ -460,6 +465,7 @@
 	"i18n_29b48a76be":"Please choose a publishing method",
 	"i18n_29efa328e5":"undistributed",
 	"i18n_2a049f4f5b":"Distribution failed",
+	"i18n_2a04f7b9be":"If the button is not available, please go to the association of the asset management ftp list to add the authorization folder allowed to be managed in the current workspace",
 	"i18n_2a0bea27c4":"execution domain",
 	"i18n_2a0c4740f1":"file",
 	"i18n_2a1d1da97a":"Package testing environment package mvn clean package -Dmaven.test.skip = true -Ptest",
@@ -572,6 +578,7 @@
 	"i18n_32d0576d85":"token",
 	"i18n_32dcc6f36e":"Restart strategy: no, always, less-stopped, on-failure",
 	"i18n_32e05f01f4":"cluster information",
+	"i18n_32e3c8b702":"Published FTP",
 	"i18n_32f882ae24":"Matches zero or more characters",
 	"i18n_330363dfc5":"success",
 	"i18n_3306c2a7c7":"read default",
@@ -659,6 +666,7 @@
 	"i18n_3b94c70734":"project status",
 	"i18n_3ba621d736":"Processing successful",
 	"i18n_3baa9f3d72":"Batch build parameters also support specified parameters, delay (delayed execution of builds, in seconds) branchName (branch name), branchTagName (tag), script (build script), resultDirFile (bundle), webhook (notification webhook)",
+	"i18n_3bc3bdc031":"Please go to [System Management] - > [Asset Management] - > [FTP Management] to add FTP, or associate and assign the newly added FTP authorization to this workspace",
 	"i18n_3bc5e602b2":"email",
 	"i18n_3bcc1c7a20":"Last Modifier",
 	"i18n_3bdab2c607":"10 Minutes",
@@ -673,6 +681,7 @@
 	"i18n_3c6fa6f667":"Cron expression",
 	"i18n_3c8eada338":"Please select an encoding method",
 	"i18n_3c91490844":"publish action",
+	"i18n_3c943b89c6":"This editor can only edit the name information of the current FTP in this workspace",
 	"i18n_3c99ea4ec2":"For example, in 2, 3, 6/3, since \"/\" has a high priority, it is equivalent to 2, 3, (6/3), and the result is equivalent to 2, 3, 6",
 	"i18n_3c9eeee356":"Do you really want to delete log files?",
 	"i18n_3cc09369ad":"Really want to delete [",
@@ -849,6 +858,7 @@
 	"i18n_4b96762a7e":"Last modification time",
 	"i18n_4b9c3271dc":"reset",
 	"i18n_4ba304e77a":"DingTalk account login",
+	"i18n_4bb37cc406":"server language",
 	"i18n_4bbc09fc55":"Search on file lines 3 - 20",
 	"i18n_4c096c51a3":"Port number:",
 	"i18n_4c0eead6ff":"new parameter",
@@ -867,6 +877,7 @@
 	"i18n_4d18dcbd15":"Do you really want to restore backup information?",
 	"i18n_4d351f3c91":"IP ban",
 	"i18n_4d49b2a15f":"Automatic execution: docker",
+	"i18n_4d4ab2f8f5":"For the authorization directory (file directory, file suffix) of the current FTP, please go to [System Management] - > [Asset Management] - > [FTP Management] - > Operation Bar - > Associated Button - > Corresponding Workspace - > Operation Bar - > Configuration Button",
 	"i18n_4d775d4cd7":"show",
 	"i18n_4d7dc6c5f8":"write",
 	"i18n_4d85ac1250":"System Management",
@@ -1126,6 +1137,7 @@
 	"i18n_649d90ab3c":"Close right",
 	"i18n_649f8046f3":"Please select an SSH node",
 	"i18n_64c083c0a9":"result description",
+	"i18n_64c8791ba1":"Configuration method: FTP management - > operation bar - > associated button - > corresponding workspace - > operation bar - > configuration button",
 	"i18n_64eee9aafa":"boot time",
 	"i18n_652273694e":"host",
 	"i18n_65571516e2":"Build Notes:",
@@ -1144,6 +1156,7 @@
 	"i18n_66ab5e9f24":"new",
 	"i18n_66b71b06c6":"Upload compressed files (automatic decompression)",
 	"i18n_66c15f2815":"Match lines containing numbers",
+	"i18n_66e623e6f8":"Configure FTP",
 	"i18n_66e9ea5488":"log name",
 	"i18n_6707667676":"hostname",
 	"i18n_6709f4548f":"random generation",
@@ -1329,6 +1342,7 @@
 	"i18n_77373db7d8":"Receive alarm message, optional, GET request",
 	"i18n_7737f088de":"batch restart",
 	"i18n_773b1a5ef6":"Please select a language mode",
+	"i18n_774aecfd99":"If you want to configure FTP, please go to [System Management] - > [Asset Management] - > [FTP Management] to configure.",
 	"i18n_775fde44cf":"Process port cache:",
 	"i18n_7760785daf":"free script",
 	"i18n_7764df7ccc":"Enabling differential releases but not empty releases is equivalent to only incremental and change updates",
@@ -1406,6 +1420,7 @@
 	"i18n_7e930b95ef":"publish file",
 	"i18n_7e951d56d9":"operating time",
 	"i18n_7e9f0d2606":"Project refers to a certain project in the node, and the project needs to be created in the node in advance",
+	"i18n_7eef73a0eb":"Edit FTP",
 	"i18n_7ef30cfd31":"Additional environment variables refer to reading the environment variables file specified by the warehouse to add to the execution build runtime",
 	"i18n_7f0abcf48d":"You need to go to the editor to bind an ssh information for a node to enable this function.",
 	"i18n_7f3809d36b":"end of build",
@@ -1424,6 +1439,7 @@
 	"i18n_8086beecb3":"Tag name:",
 	"i18n_808c18d2bb":"A value of true indicates that the project is currently running",
 	"i18n_809b12d6a0":"Please be patient, there is no need to refresh the page for the time being.",
+	"i18n_80b7af89ee":"There is no FTP in the current workspace.",
 	"i18n_80cfc33cbe":"Confirm reset",
 	"i18n_81301b6813":"Open end point",
 	"i18n_81485b76d8":"Please enter the host address",
@@ -1449,6 +1465,7 @@
 	"i18n_8306971039":"user",
 	"i18n_8309cec640":"Please select the node project. It may be that there is no project in the node, and you need to go to the node to create a project.",
 	"i18n_833249fb92":"Current file time",
+	"i18n_8339e5e8e9":"If you multi-select ftp, the directory below only displays the first item in the options, but the authorization directory needs to ensure that each item is configured with the corresponding directory",
 	"i18n_8347a927c0":"modify",
 	"i18n_835050418f":"Are you sure you want to upload the latest plugin package?",
 	"i18n_8351876236":"Alias",
@@ -1635,6 +1652,7 @@
 	"i18n_917381e4a5":"Current download source:",
 	"i18n_91985e3574":"automatic detection",
 	"i18n_91a10b8776":" script library ",
+	"i18n_91a828d055":"Do you really want to clear FTP hidden field information? (password)",
 	"i18n_920f05031b":"state description",
 	"i18n_922b76febd":"Run mode Required",
 	"i18n_923f8d2688":"Post-issue command",
@@ -1867,6 +1885,7 @@
 	"i18n_a436c94494":"Feishu scan code",
 	"i18n_a472019766":"Node Id",
 	"i18n_a497562c8e":"executor",
+	"i18n_a49f609d09":"Active Mode",
 	"i18n_a4f5cae8d2":"on state",
 	"i18n_a4f629041c":"The path needs to be configured with an absolute path.",
 	"i18n_a50fbc5a52":"Support specifying the network interface card name to bind:",
@@ -2345,6 +2364,7 @@
 	"i18n_cd998f12fa":"When a node already exists in the target workspace, the node authorization information and agent configuration information will be automatically synchronized",
 	"i18n_cda84be2f6":"operation log",
 	"i18n_cdc478d90c":"system name",
+	"i18n_cdf2e36c2a":"FTP name",
 	"i18n_ce043fac7d":"The current workspace does not have SSH yet.",
 	"i18n_ce07501354":"Click on a number to see a running task",
 	"i18n_ce1c5765e4":"View Release Template",
@@ -2358,6 +2378,7 @@
 	"i18n_ceee1db95a":"container port",
 	"i18n_ceffe5d643":"two-step verification app",
 	"i18n_cf38e8f9fd":"The authorization path configuration currently distributed for the node",
+	"i18n_cf93cd2cde":"Please select FTP.",
 	"i18n_cfa72dd73a":"Please enter the cron expression to check",
 	"i18n_cfb00269fd":"Execute script",
 	"i18n_cfbb3341d5":"The currently logged in account is:",
@@ -2411,6 +2432,7 @@
 	"i18n_d4aea8d7e6":"execution times",
 	"i18n_d4e03f60a9":"Check the project status when the plug-in side starts, and try to execute the startup project if the project status is not running.",
 	"i18n_d5269713c7":"Indicates that when bundled as a folder, it will be packaged as",
+	"i18n_d55b5f6ce4":"transmission mode",
 	"i18n_d57796d6ac":" : Range: 0~ 59",
 	"i18n_d584e1493b":"Search ssh name",
 	"i18n_d58a55bcee":"close",
@@ -2433,6 +2455,7 @@
 	"i18n_d731dc9325":"Timestamp:",
 	"i18n_d7471c0261":"Please select an execution node",
 	"i18n_d75c02d050":"Stop project",
+	"i18n_d769de863b":"Associated workspace ftp",
 	"i18n_d7ac764d3a":"The distribution interval time (sequential restart, full sequential restart) method will only take effect",
 	"i18n_d7ba18c360":"The distribution node refers to a script that automatically synchronizes the content of the script node after editing the script",
 	"i18n_d7bebd0e5e":"Please go to the console to control the status operation.",
@@ -2536,6 +2559,7 @@
 	"i18n_df9d1fedc5":"Node distribution refers to the deployment of a project in multiple nodes using node distribution to complete project publishing operations in multiple nodes in one step",
 	"i18n_dfb8d511c7":"user name",
 	"i18n_dfcc9e3c45":"post-distribution operation",
+	"i18n_e020a4df74":"Server system keywords",
 	"i18n_e039ffccc8":"Restore this file to the project directory?",
 	"i18n_e049546ff3":"Modified in [System Configuration Catalog]",
 	"i18n_e06497b0fb":"View currently available containers",
@@ -2645,9 +2669,11 @@
 	"i18n_ea8a79546f":"Please enter the published file id.",
 	"i18n_ea9f824647":"Pull warehouse timeout in seconds",
 	"i18n_eaa5d7cb9b":"expiration date",
+	"i18n_eaa85849f3":"If ftp does not configure the authorization directory, it cannot be selected.",
 	"i18n_eadd05ba6a":"medium",
 	"i18n_eaf987eea0":"Weight (relative weight).",
 	"i18n_eb164b696d":"exclude publishing",
+	"i18n_eb3a60fbc0":"Edit FTP",
 	"i18n_eb5bab1c31":"optional",
 	"i18n_eb79cea638":"Friday",
 	"i18n_eb7f9ceb71":"Script library:",
@@ -2706,6 +2732,7 @@
 	"i18n_f038f48ce5":"Edit script",
 	"i18n_f04a289502":"Svn ssh required Login user",
 	"i18n_f05e3ec44d":"Forbidden access, current IP restricts access",
+	"i18n_f06a391743":"The FTP that has been assigned to the workspace cannot be deleted directly. You need to delete the asset FTP one by one in each allocated workspace.",
 	"i18n_f06f95f8e6":"Lonely Data",
 	"i18n_f087eb347c":"Build command example",
 	"i18n_f08afd1f82":"Selected",
@@ -2807,6 +2834,7 @@
 	"i18n_fad1b9fb87":"The new script template needs to be added to the node management.",
 	"i18n_fb1f3b5125":"Current Workspace Linked Data Statistics",
 	"i18n_fb3a2241bb":"Status description:",
+	"i18n_fb5037a644":"This configuration is only valid for server level administration, the ftp configuration of the workspace needs to be configured separately",
 	"i18n_fb5bc565f3":"Failed to parse file:",
 	"i18n_fb61d4d708":"Do you really want to roll back the build history?",
 	"i18n_fb7b9876a6":"Please enter a script name",
@@ -2831,6 +2859,7 @@
 	"i18n_fcbf0d0a55":"You need to install the dependent yarn & & yarn run build first.",
 	"i18n_fcca8452fe":"The cluster address is mainly used to switch the workspace and automatically jump to the corresponding cluster.",
 	"i18n_fcef976c7a":"private key content",
+	"i18n_fcfbc11bb9":"The password field will not be returned when editing. Please click me if you need to reset or empty it.",
 	"i18n_fd6e80f1e0":"normal",
 	"i18n_fd7b461411":"Do not empty",
 	"i18n_fd7e0c997d":"Select file",

+ 29 - 0
web-vue/src/i18n/locales/zh_cn.json

@@ -30,6 +30,7 @@
   "i18n_02e35447d4": "下载构建产物,如果按钮不可用表示产物文件不存在,一般是构建没有产生对应的文件或者构建历史相关文件被删除",
   "i18n_0306ea1908": "删除镜像",
   "i18n_031020489f": "当前工作空间您触发的构建记录",
+  "i18n_03113c0f1a": "真的要删除 FTP 么?",
   "i18n_03580275cb": "请选中要重启的项目",
   "i18n_0360fffb40": "并开启此开关",
   "i18n_036c0dc2aa": "系统取消分发",
@@ -83,6 +84,7 @@
   "i18n_080b914139": "上传包",
   "i18n_0836332bf6": "升级协议",
   "i18n_083b8a2ec9": "一个物理节点被多个服务端绑定也会产生孤独数据奥",
+  "i18n_087c992cc0": "被动模式",
   "i18n_08902526f1": "皮肤:",
   "i18n_0895c740a6": "交换内存占用",
   "i18n_089a88ecee": "系统时间:",
@@ -136,6 +138,7 @@
   "i18n_0ce54ecc25": "付费社群",
   "i18n_0cf4f0ba82": "真的要保存当前配置吗?如果配置有误,可能无法启动服务需要手动还原奥!!! 保存成功后请及时关注重启状态!!",
   "i18n_0cf81d77bb": "请填写仓库地址",
+  "i18n_0d1ee51203": "系统关键词",
   "i18n_0d44f4903a": "真的要释放(删除)当前项目么?",
   "i18n_0d467f7889": "# 是否开启日志备份功能",
   "i18n_0d48f8e881": "请输入服务地址",
@@ -213,6 +216,7 @@
   "i18n_143bfbc3a1": "点击重新同步当前工作空间逻辑节点项目信息",
   "i18n_143d8d3de5": "否则将删除满足条件的所有数据",
   "i18n_148484b985": "实现您需要配置 docker 容器到服务端中来管理,并且分配到当前工作空间中",
+  "i18n_148d37218a": "FTP 名称",
   "i18n_1498557b2d": "同时只能展开一个菜单",
   "i18n_14a25beebb": "10秒一次",
   "i18n_14d342362f": "标签",
@@ -281,6 +285,7 @@
   "i18n_1a8f90122f": "提示信息 ",
   "i18n_1abf39bdb6": "# 将此目录缓存到全局(多个构建可以共享此缓存目录)",
   "i18n_1ad696efdc": "构建执行的命令(非阻塞命令),如:mvn clean package、npm run build。支持变量:{'${BUILD_ID}'}、{'${BUILD_NAME}'}、{'${BUILD_SOURCE_FILE}'}、{'${BUILD_NUMBER_ID}'}、仓库目录下 .env、工作空间变量",
+  "i18n_1add83f77b": "ftp名称",
   "i18n_1ae2955867": "指定 pom 文件打包 mvn -f xxx/pom.xml clean package",
   "i18n_1afdb4a364": "隐藏滚动条。纵向滚动方式提醒:滚轮,横行滚动方式:Shift+滚轮",
   "i18n_1b03b0c1ff": "已经分配到工作空间的 Docker 或者集群无法直接删除,需要到分配到的各个工作空间逐一删除后才能删除资产 Docker 或者集群",
@@ -460,6 +465,7 @@
   "i18n_29b48a76be": "请选择发布方式",
   "i18n_29efa328e5": "未分发",
   "i18n_2a049f4f5b": "分发失败",
+  "i18n_2a04f7b9be": "如果按钮不可用,请去资产管理 ftp 列表的关联中新增当前工作空间允许管理的授权文件夹",
   "i18n_2a0bea27c4": "执行域",
   "i18n_2a0c4740f1": "文件",
   "i18n_2a1d1da97a": "打包测试环境包 mvn clean package -Dmaven.test.skip=true -Ptest",
@@ -572,6 +578,7 @@
   "i18n_32d0576d85": "的令牌",
   "i18n_32dcc6f36e": "重启策略:no、always、unless-stopped、on-failure",
   "i18n_32e05f01f4": "集群信息",
+  "i18n_32e3c8b702": "发布的FTP",
   "i18n_32f882ae24": "匹配零个或多个字符",
   "i18n_330363dfc5": "成功",
   "i18n_3306c2a7c7": "读取默认",
@@ -659,6 +666,7 @@
   "i18n_3b94c70734": "项目状态",
   "i18n_3ba621d736": "处理成功",
   "i18n_3baa9f3d72": "批量构建参数还支持指定参数,delay(延迟执行构建,单位秒)branchName(分支名)、branchTagName(标签)、script(构建脚本)、resultDirFile(构建产物)、webhook(通知webhook)",
+  "i18n_3bc3bdc031": "请到【系统管理】-> 【资产管理】-> 【FTP管理】新增FTP,或者将已新增的FTP授权关联、分配到此工作空间",
   "i18n_3bc5e602b2": "邮箱",
   "i18n_3bcc1c7a20": "最后修改人",
   "i18n_3bdab2c607": "10分钟",
@@ -673,6 +681,7 @@
   "i18n_3c6fa6f667": "cron表达式",
   "i18n_3c8eada338": "请选择编码方式",
   "i18n_3c91490844": "发布操作",
+  "i18n_3c943b89c6": "此编辑仅能编辑当前 FTP 在此工作空间的名称信息",
   "i18n_3c99ea4ec2": "例如 2,3,6/3中,由于“/”优先级高,因此相当于2,3,(6/3),结果与 2,3,6等价",
   "i18n_3c9eeee356": "真的要删除日志文件么?",
   "i18n_3cc09369ad": "真的要删除【",
@@ -849,6 +858,7 @@
   "i18n_4b96762a7e": "最后修改时间",
   "i18n_4b9c3271dc": "重置",
   "i18n_4ba304e77a": "钉钉账号登录",
+  "i18n_4bb37cc406": "服务器语言",
   "i18n_4bbc09fc55": "在文件第 3 - 20 行中搜索",
   "i18n_4c096c51a3": "端口号:",
   "i18n_4c0eead6ff": "新增参数",
@@ -867,6 +877,7 @@
   "i18n_4d18dcbd15": "真的要还原备份信息么?",
   "i18n_4d351f3c91": "禁止 IP",
   "i18n_4d49b2a15f": "自动执行:docker",
+  "i18n_4d4ab2f8f5": "当前 FTP 的授权目录(文件目录、文件后缀)需要请到 【系统管理】-> 【资产管理】-> 【FTP 管理】-> 操作栏中->关联按钮->对应工作空间->操作栏中->配置按钮",
   "i18n_4d775d4cd7": "显示",
   "i18n_4d7dc6c5f8": "写",
   "i18n_4d85ac1250": "系统管理",
@@ -1126,6 +1137,7 @@
   "i18n_649d90ab3c": "关闭右侧",
   "i18n_649f8046f3": "请选择SSH节点",
   "i18n_64c083c0a9": "结果描述",
+  "i18n_64c8791ba1": "配置方式:FTP管理->操作栏中->关联按钮->对应工作空间->操作栏中->配置按钮",
   "i18n_64eee9aafa": "开机时间",
   "i18n_652273694e": "主机",
   "i18n_65571516e2": "构建备注:",
@@ -1144,6 +1156,7 @@
   "i18n_66ab5e9f24": "新增",
   "i18n_66b71b06c6": "上传压缩文件(自动解压)",
   "i18n_66c15f2815": "匹配包含数字的行",
+  "i18n_66e623e6f8": "配置ftp",
   "i18n_66e9ea5488": "日志名称",
   "i18n_6707667676": "主机名",
   "i18n_6709f4548f": "随机生成",
@@ -1329,6 +1342,7 @@
   "i18n_77373db7d8": "接收报警消息,非必填,GET请求",
   "i18n_7737f088de": "批量重新启动",
   "i18n_773b1a5ef6": "请选择语言模式",
+  "i18n_774aecfd99": "如果要配置 FTP 请到【系统管理】-> 【资产管理】-> 【FTP 管理】中去配置。",
   "i18n_775fde44cf": "进程端口缓存:",
   "i18n_7760785daf": "自由脚本",
   "i18n_7764df7ccc": "开启差异发布但不开启清空发布时相当于只做增量和变动更新",
@@ -1406,6 +1420,7 @@
   "i18n_7e930b95ef": "发布文件",
   "i18n_7e951d56d9": "操作时间",
   "i18n_7e9f0d2606": "项目是指,节点中的某一个项目,需要提前在节点中创建项目",
+  "i18n_7eef73a0eb": "编辑FTP",
   "i18n_7ef30cfd31": "附加环境变量是指读取仓库指定环境变量文件来新增到执行构建运行时",
   "i18n_7f0abcf48d": "需要到编辑中去为一个节点绑定一个 ssh信息才能启用该功能",
   "i18n_7f3809d36b": "构建结束",
@@ -1424,6 +1439,7 @@
   "i18n_8086beecb3": "标签名称:",
   "i18n_808c18d2bb": "值为 true 表示项目当前为运行中",
   "i18n_809b12d6a0": "请耐心等待暂时不用刷新页面",
+  "i18n_80b7af89ee": "当前工作空间还没有FTP",
   "i18n_80cfc33cbe": "确认重置",
   "i18n_81301b6813": "打开终端",
   "i18n_81485b76d8": "请输入主机地址",
@@ -1449,6 +1465,7 @@
   "i18n_8306971039": "所属用户",
   "i18n_8309cec640": "请选择节点项目,可能是节点中不存在任何项目,需要去节点中创建项目",
   "i18n_833249fb92": "当前文件用时",
+  "i18n_8339e5e8e9": "如果多选 ftp 下面目录只显示选项中的第一项,但是授权目录需要保证每项都配置对应目录",
   "i18n_8347a927c0": "修改",
   "i18n_835050418f": "确认要上传最新的插件包吗?",
   "i18n_8351876236": "别名 ",
@@ -1635,6 +1652,7 @@
   "i18n_917381e4a5": "当前下载源:",
   "i18n_91985e3574": "自动探测",
   "i18n_91a10b8776": " 脚本库 ",
+  "i18n_91a828d055": "真的要清除 FTP 隐藏字段信息么?(密码)",
   "i18n_920f05031b": "状态描述",
   "i18n_922b76febd": "运行模式必填",
   "i18n_923f8d2688": "发布后命令",
@@ -1867,6 +1885,7 @@
   "i18n_a436c94494": "飞书扫码",
   "i18n_a472019766": "节点Id",
   "i18n_a497562c8e": "执行人",
+  "i18n_a49f609d09": "主动模式",
   "i18n_a4f5cae8d2": "开启状态",
   "i18n_a4f629041c": "路径需要配置绝对路径",
   "i18n_a50fbc5a52": "支持指定网卡名称来绑定:",
@@ -2345,6 +2364,7 @@
   "i18n_cd998f12fa": "当目标工作空间已经存在节点时候将自动同步节点授权信息、代理配置信息",
   "i18n_cda84be2f6": "操作日志",
   "i18n_cdc478d90c": "系统名",
+  "i18n_cdf2e36c2a": "FTP名称",
   "i18n_ce043fac7d": "当前工作空间还没有SSH",
   "i18n_ce07501354": "点击数字查看运行中的任务",
   "i18n_ce1c5765e4": "查看发布模板",
@@ -2358,6 +2378,7 @@
   "i18n_ceee1db95a": "容器端口",
   "i18n_ceffe5d643": "两步验证应用",
   "i18n_cf38e8f9fd": "当前为节点分发的授权路径配置",
+  "i18n_cf93cd2cde": "请选择FTP",
   "i18n_cfa72dd73a": "请输入要检查的 cron 表达式",
   "i18n_cfb00269fd": "执行脚本",
   "i18n_cfbb3341d5": "当前登录的账号是:",
@@ -2411,6 +2432,7 @@
   "i18n_d4aea8d7e6": "执行次数",
   "i18n_d4e03f60a9": "插件端启动的时候检查项目状态,如果项目状态是未运行则尝试执行启动项目",
   "i18n_d5269713c7": "表示构建产物为文件夹时将打包为",
+  "i18n_d55b5f6ce4": "传输模式",
   "i18n_d57796d6ac": " :范围:0~59",
   "i18n_d584e1493b": "搜索ssh名称",
   "i18n_d58a55bcee": "关",
@@ -2433,6 +2455,7 @@
   "i18n_d731dc9325": "时间戳:",
   "i18n_d7471c0261": "请选择执行节点",
   "i18n_d75c02d050": "停止项目",
+  "i18n_d769de863b": "关联工作空间ftp",
   "i18n_d7ac764d3a": "分发间隔时间 (顺序重启、完整顺序重启)方式才生效",
   "i18n_d7ba18c360": "分发节点是指在编辑完脚本后自动将脚本内容同步节点的脚本中",
   "i18n_d7bebd0e5e": "状态操作请到控制台中控制",
@@ -2536,6 +2559,7 @@
   "i18n_df9d1fedc5": "节点分发是指,一个项目部署在多个节点中使用节点分发一步完成多个节点中的项目发布操作",
   "i18n_dfb8d511c7": "用户名称",
   "i18n_dfcc9e3c45": "分发后操作",
+  "i18n_e020a4df74": "服务器系统关键词",
   "i18n_e039ffccc8": "】此文件还原到项目目录?",
   "i18n_e049546ff3": "【系统配置目录】中修改",
   "i18n_e06497b0fb": "查看当前可用容器",
@@ -2645,9 +2669,11 @@
   "i18n_ea8a79546f": "请输入发布的文件id",
   "i18n_ea9f824647": "拉取仓库超时时间,单位秒",
   "i18n_eaa5d7cb9b": "过期天数",
+  "i18n_eaa85849f3": "如果 ftp 没有配置授权目录是不能选择的哟",
   "i18n_eadd05ba6a": "中等",
   "i18n_eaf987eea0": "权重(相对权重)。",
   "i18n_eb164b696d": "排除发布",
+  "i18n_eb3a60fbc0": "编辑 FTP",
   "i18n_eb5bab1c31": "非必填",
   "i18n_eb79cea638": "周五",
   "i18n_eb7f9ceb71": "脚本库:",
@@ -2706,6 +2732,7 @@
   "i18n_f038f48ce5": "编辑脚本",
   "i18n_f04a289502": "svn ssh 必填登录用户",
   "i18n_f05e3ec44d": "禁止访问,当前IP限制访问",
+  "i18n_f06a391743": "已经分配到工作空间的 FTP 无法直接删除,需要到分配到的各个工作空间逐一删除后才能删除资产 FTP",
   "i18n_f06f95f8e6": "孤独数据",
   "i18n_f087eb347c": "构建命令示例",
   "i18n_f08afd1f82": "已选择",
@@ -2807,6 +2834,7 @@
   "i18n_fad1b9fb87": "新增脚本模版需要到节点管理中去新增",
   "i18n_fb1f3b5125": "当前工作空间关联数据统计",
   "i18n_fb3a2241bb": "状态描述:",
+  "i18n_fb5037a644": "此配置仅对服务端管理生效, 工作空间的 ftp 配置需要单独配置",
   "i18n_fb5bc565f3": "解析文件失败:",
   "i18n_fb61d4d708": "真的要回滚该构建历史记录么?",
   "i18n_fb7b9876a6": "请输入脚本名称",
@@ -2831,6 +2859,7 @@
   "i18n_fcbf0d0a55": "需要先安装依赖 yarn && yarn run build",
   "i18n_fcca8452fe": "集群地址主要用于切换工作空间自动跳转到对应的集群",
   "i18n_fcef976c7a": "私钥内容",
+  "i18n_fcfbc11bb9": "密码字段在编辑的时候不会返回,如果需要重置或者清空就请点我",
   "i18n_fd6e80f1e0": "正常",
   "i18n_fd7b461411": "不清空",
   "i18n_fd7e0c997d": "选择文件",

+ 29 - 0
web-vue/src/i18n/locales/zh_hk.json

@@ -30,6 +30,7 @@
 	"i18n_02e35447d4":"下載構建產物,如果按鈕不可用表示產物文件不存在,一般是構建沒有產生對應的文件或者構建歷史相關文件被刪除",
 	"i18n_0306ea1908":"刪除鏡像",
 	"i18n_031020489f":"當前工作空間您觸發的構建記錄",
+	"i18n_03113c0f1a":"真的要刪除 FTP 麼?",
 	"i18n_03580275cb":"請選中要重啟的項目",
 	"i18n_0360fffb40":"並開啟此開關",
 	"i18n_036c0dc2aa":"系統取消分發",
@@ -83,6 +84,7 @@
 	"i18n_080b914139":"上傳包",
 	"i18n_0836332bf6":"升級協議",
 	"i18n_083b8a2ec9":"一個物理節點被多個服務端綁定也會產生孤獨數據奧",
+	"i18n_087c992cc0":"被動模式",
 	"i18n_08902526f1":"皮膚:",
 	"i18n_0895c740a6":"交換內存佔用",
 	"i18n_089a88ecee":"系統時間:",
@@ -136,6 +138,7 @@
 	"i18n_0ce54ecc25":"付費社羣",
 	"i18n_0cf4f0ba82":"真的要保存當前配置嗎?如果配置有誤,可能無法啟動服務需要手動還原奧!!! 保存成功後請及時關注重啟狀態!!",
 	"i18n_0cf81d77bb":"請填寫倉庫地址",
+	"i18n_0d1ee51203":"系統關鍵詞",
 	"i18n_0d44f4903a":"真的要釋放(刪除)當前項目麼?",
 	"i18n_0d467f7889":"# 是否開啟日誌備份功能",
 	"i18n_0d48f8e881":"請輸入服務地址",
@@ -213,6 +216,7 @@
 	"i18n_143bfbc3a1":"點擊重新同步當前工作空間邏輯節點項目信息",
 	"i18n_143d8d3de5":"否則將刪除滿足條件的所有數據",
 	"i18n_148484b985":"實現您需要配置 docker 容器到服務端中來管理,並且分配到當前工作空間中",
+	"i18n_148d37218a":"FTP 名稱",
 	"i18n_1498557b2d":"同時只能展開一個菜單",
 	"i18n_14a25beebb":"10秒一次",
 	"i18n_14d342362f":"標籤",
@@ -281,6 +285,7 @@
 	"i18n_1a8f90122f":"提示信息 ",
 	"i18n_1abf39bdb6":"# 將此目錄緩存到全局(多個構建可以共享此緩存目錄)",
 	"i18n_1ad696efdc":"構建執行的命令(非阻塞命令),如:mvn clean package、npm run build。支持變量:{'${BUILD_ID}'}、{'${BUILD_NAME}'}、{'${BUILD_SOURCE_FILE}'}、{'${BUILD_NUMBER_ID}'}、倉庫目錄下 .env、工作空間變量",
+	"i18n_1add83f77b":"ftp名稱",
 	"i18n_1ae2955867":"指定 pom 文件打包 mvn -f xxx/pom.xml clean package",
 	"i18n_1afdb4a364":"隱藏滾動條。縱向滾動方式提醒:滾輪,橫行滾動方式:Shift+滾輪",
 	"i18n_1b03b0c1ff":"已經分配到工作空間的 Docker 或者集羣無法直接刪除,需要到分配到的各個工作空間逐一刪除後才能刪除資產 Docker 或者集羣",
@@ -460,6 +465,7 @@
 	"i18n_29b48a76be":"請選擇發佈方式",
 	"i18n_29efa328e5":"未分發",
 	"i18n_2a049f4f5b":"分發失敗",
+	"i18n_2a04f7b9be":"如果按鈕不可用,請去資產管理 ftp 列表的關聯中新增當前工作空間允許管理的授權文件夾",
 	"i18n_2a0bea27c4":"執行域",
 	"i18n_2a0c4740f1":"文件",
 	"i18n_2a1d1da97a":"打包測試環境包 mvn clean package -Dmaven.test.skip=true -Ptest",
@@ -572,6 +578,7 @@
 	"i18n_32d0576d85":"的令牌",
 	"i18n_32dcc6f36e":"重啟策略:no、always、unless-stopped、on-failure",
 	"i18n_32e05f01f4":"集羣信息",
+	"i18n_32e3c8b702":"發佈的FTP",
 	"i18n_32f882ae24":"匹配零個或多個字符",
 	"i18n_330363dfc5":"成功",
 	"i18n_3306c2a7c7":"讀取默認",
@@ -659,6 +666,7 @@
 	"i18n_3b94c70734":"項目狀態",
 	"i18n_3ba621d736":"處理成功",
 	"i18n_3baa9f3d72":"批量構建參數還支持指定參數,delay(延遲執行構建,單位秒)branchName(分支名)、branchTagName(標籤)、script(構建腳本)、resultDirFile(構建產物)、webhook(通知webhook)",
+	"i18n_3bc3bdc031":"請到【系統管理】-> 【資產管理】-> 【FTP管理】新增FTP,或者將已新增的FTP授權關聯、分配到此工作空間",
 	"i18n_3bc5e602b2":"郵箱",
 	"i18n_3bcc1c7a20":"最後修改人",
 	"i18n_3bdab2c607":"10分鐘",
@@ -673,6 +681,7 @@
 	"i18n_3c6fa6f667":"cron表達式",
 	"i18n_3c8eada338":"請選擇編碼方式",
 	"i18n_3c91490844":"發佈操作",
+	"i18n_3c943b89c6":"此編輯僅能編輯當前 FTP 在此工作空間的名稱信息",
 	"i18n_3c99ea4ec2":"例如 2,3,6/3中,由於“/”優先級高,因此相當於2,3,(6/3),結果與 2,3,6等價",
 	"i18n_3c9eeee356":"真的要刪除日誌文件麼?",
 	"i18n_3cc09369ad":"真的要刪除【",
@@ -849,6 +858,7 @@
 	"i18n_4b96762a7e":"最後修改時間",
 	"i18n_4b9c3271dc":"重置",
 	"i18n_4ba304e77a":"釘釘賬號登錄",
+	"i18n_4bb37cc406":"服務器語言",
 	"i18n_4bbc09fc55":"在文件第 3 - 20 行中搜索",
 	"i18n_4c096c51a3":"端口號:",
 	"i18n_4c0eead6ff":"新增參數",
@@ -867,6 +877,7 @@
 	"i18n_4d18dcbd15":"真的要還原備份信息麼?",
 	"i18n_4d351f3c91":"禁止 IP",
 	"i18n_4d49b2a15f":"自動執行:docker",
+	"i18n_4d4ab2f8f5":"當前 FTP 的授權目錄(文件目錄、文件後綴)需要請到 【系統管理】-> 【資產管理】-> 【FTP 管理】-> 操作欄中->關聯按鈕->對應工作空間->操作欄中->配置按鈕",
 	"i18n_4d775d4cd7":"顯示",
 	"i18n_4d7dc6c5f8":"寫",
 	"i18n_4d85ac1250":"系統管理",
@@ -1126,6 +1137,7 @@
 	"i18n_649d90ab3c":"關閉右側",
 	"i18n_649f8046f3":"請選擇SSH節點",
 	"i18n_64c083c0a9":"結果描述",
+	"i18n_64c8791ba1":"配置方式:FTP管理->操作欄中->關聯按鈕->對應工作空間->操作欄中->配置按鈕",
 	"i18n_64eee9aafa":"開機時間",
 	"i18n_652273694e":"主機",
 	"i18n_65571516e2":"構建備註:",
@@ -1144,6 +1156,7 @@
 	"i18n_66ab5e9f24":"新增",
 	"i18n_66b71b06c6":"上傳壓縮文件(自動解壓)",
 	"i18n_66c15f2815":"匹配包含數字的行",
+	"i18n_66e623e6f8":"配置ftp",
 	"i18n_66e9ea5488":"日誌名稱",
 	"i18n_6707667676":"主機名",
 	"i18n_6709f4548f":"隨機生成",
@@ -1329,6 +1342,7 @@
 	"i18n_77373db7d8":"接收報警消息,非必填,GET請求",
 	"i18n_7737f088de":"批量重新啟動",
 	"i18n_773b1a5ef6":"請選擇語言模式",
+	"i18n_774aecfd99":"如果要配置 FTP 請到【系統管理】-> 【資產管理】-> 【FTP 管理】中去配置。",
 	"i18n_775fde44cf":"進程端口緩存:",
 	"i18n_7760785daf":"自由腳本",
 	"i18n_7764df7ccc":"開啟差異發佈但不開啟清空發佈時相當於只做增量和變動更新",
@@ -1406,6 +1420,7 @@
 	"i18n_7e930b95ef":"發佈文件",
 	"i18n_7e951d56d9":"操作時間",
 	"i18n_7e9f0d2606":"項目是指,節點中的某一個項目,需要提前在節點中創建項目",
+	"i18n_7eef73a0eb":"編輯FTP",
 	"i18n_7ef30cfd31":"附加環境變量是指讀取倉庫指定環境變量文件來新增到執行構建運行時",
 	"i18n_7f0abcf48d":"需要到編輯中去為一個節點綁定一個 ssh信息才能啟用該功能",
 	"i18n_7f3809d36b":"構建結束",
@@ -1424,6 +1439,7 @@
 	"i18n_8086beecb3":"標籤名稱:",
 	"i18n_808c18d2bb":"值為 true 表示項目當前為運行中",
 	"i18n_809b12d6a0":"請耐心等待暫時不用刷新頁面",
+	"i18n_80b7af89ee":"當前工作空間還沒有FTP",
 	"i18n_80cfc33cbe":"確認重置",
 	"i18n_81301b6813":"打開終端",
 	"i18n_81485b76d8":"請輸入主機地址",
@@ -1449,6 +1465,7 @@
 	"i18n_8306971039":"所屬用户",
 	"i18n_8309cec640":"請選擇節點項目,可能是節點中不存在任何項目,需要去節點中創建項目",
 	"i18n_833249fb92":"當前文件用時",
+	"i18n_8339e5e8e9":"如果多選 ftp 下面目錄只顯示選項中的第一項,但是授權目錄需要保證每項都配置對應目錄",
 	"i18n_8347a927c0":"修改",
 	"i18n_835050418f":"確認要上傳最新的插件包嗎?",
 	"i18n_8351876236":"別名 ",
@@ -1635,6 +1652,7 @@
 	"i18n_917381e4a5":"當前下載源:",
 	"i18n_91985e3574":"自動探測",
 	"i18n_91a10b8776":" 腳本庫 ",
+	"i18n_91a828d055":"真的要清除 FTP 隱藏字段信息麼?(密碼)",
 	"i18n_920f05031b":"狀態描述",
 	"i18n_922b76febd":"運行模式必填",
 	"i18n_923f8d2688":"發佈後命令",
@@ -1867,6 +1885,7 @@
 	"i18n_a436c94494":"飛書掃碼",
 	"i18n_a472019766":"節點Id",
 	"i18n_a497562c8e":"執行人",
+	"i18n_a49f609d09":"主動模式",
 	"i18n_a4f5cae8d2":"開啟狀態",
 	"i18n_a4f629041c":"路徑需要配置絕對路徑",
 	"i18n_a50fbc5a52":"支持指定網卡名稱來綁定:",
@@ -2345,6 +2364,7 @@
 	"i18n_cd998f12fa":"當目標工作空間已經存在節點時候將自動同步節點授權信息、代理配置信息",
 	"i18n_cda84be2f6":"操作日誌",
 	"i18n_cdc478d90c":"系統名",
+	"i18n_cdf2e36c2a":"FTP名稱",
 	"i18n_ce043fac7d":"當前工作空間還沒有SSH",
 	"i18n_ce07501354":"點擊數字查看運行中的任務",
 	"i18n_ce1c5765e4":"查看發佈模板",
@@ -2358,6 +2378,7 @@
 	"i18n_ceee1db95a":"容器端口",
 	"i18n_ceffe5d643":"兩步驗證應用",
 	"i18n_cf38e8f9fd":"當前為節點分發的授權路徑配置",
+	"i18n_cf93cd2cde":"請選擇FTP",
 	"i18n_cfa72dd73a":"請輸入要檢查的 cron 表達式",
 	"i18n_cfb00269fd":"執行腳本",
 	"i18n_cfbb3341d5":"當前登錄的賬號是:",
@@ -2411,6 +2432,7 @@
 	"i18n_d4aea8d7e6":"執行次數",
 	"i18n_d4e03f60a9":"插件端啟動的時候檢查項目狀態,如果項目狀態是未運行則嘗試執行啟動項目",
 	"i18n_d5269713c7":"表示構建產物為文件夾時將打包為",
+	"i18n_d55b5f6ce4":"傳輸模式",
 	"i18n_d57796d6ac":" :範圍:0~59",
 	"i18n_d584e1493b":"搜索ssh名稱",
 	"i18n_d58a55bcee":"關",
@@ -2433,6 +2455,7 @@
 	"i18n_d731dc9325":"時間戳:",
 	"i18n_d7471c0261":"請選擇執行節點",
 	"i18n_d75c02d050":"停止項目",
+	"i18n_d769de863b":"關聯工作空間ftp",
 	"i18n_d7ac764d3a":"分發間隔時間 (順序重啟、完整順序重啟)方式才生效",
 	"i18n_d7ba18c360":"分發節點是指在編輯完腳本後自動將腳本內容同步節點的腳本中",
 	"i18n_d7bebd0e5e":"狀態操作請到控制枱中控制",
@@ -2536,6 +2559,7 @@
 	"i18n_df9d1fedc5":"節點分發是指,一個項目部署在多個節點中使用節點分發一步完成多個節點中的項目發佈操作",
 	"i18n_dfb8d511c7":"用户名稱",
 	"i18n_dfcc9e3c45":"分發後操作",
+	"i18n_e020a4df74":"服務器系統關鍵詞",
 	"i18n_e039ffccc8":"】此文件還原到項目目錄?",
 	"i18n_e049546ff3":"【系統配置目錄】中修改",
 	"i18n_e06497b0fb":"查看當前可用容器",
@@ -2645,9 +2669,11 @@
 	"i18n_ea8a79546f":"請輸入發佈的文件id",
 	"i18n_ea9f824647":"拉取倉庫超時時間,單位秒",
 	"i18n_eaa5d7cb9b":"過期天數",
+	"i18n_eaa85849f3":"如果 ftp 沒有配置授權目錄是不能選擇的喲",
 	"i18n_eadd05ba6a":"中等",
 	"i18n_eaf987eea0":"權重(相對權重)。",
 	"i18n_eb164b696d":"排除發佈",
+	"i18n_eb3a60fbc0":"編輯 FTP",
 	"i18n_eb5bab1c31":"非必填",
 	"i18n_eb79cea638":"週五",
 	"i18n_eb7f9ceb71":"腳本庫:",
@@ -2706,6 +2732,7 @@
 	"i18n_f038f48ce5":"編輯腳本",
 	"i18n_f04a289502":"svn ssh 必填登錄用户",
 	"i18n_f05e3ec44d":"禁止訪問,當前IP限制訪問",
+	"i18n_f06a391743":"已經分配到工作空間的 FTP 無法直接刪除,需要到分配到的各個工作空間逐一刪除後才能刪除資產 FTP",
 	"i18n_f06f95f8e6":"孤獨數據",
 	"i18n_f087eb347c":"構建命令示例",
 	"i18n_f08afd1f82":"已選擇",
@@ -2807,6 +2834,7 @@
 	"i18n_fad1b9fb87":"新增腳本模版需要到節點管理中去新增",
 	"i18n_fb1f3b5125":"當前工作空間關聯數據統計",
 	"i18n_fb3a2241bb":"狀態描述:",
+	"i18n_fb5037a644":"此配置僅對服務端管理生效, 工作空間的 ftp 配置需要單獨配置",
 	"i18n_fb5bc565f3":"解析文件失敗:",
 	"i18n_fb61d4d708":"真的要回滾該構建歷史記錄麼?",
 	"i18n_fb7b9876a6":"請輸入腳本名稱",
@@ -2831,6 +2859,7 @@
 	"i18n_fcbf0d0a55":"需要先安裝依賴 yarn && yarn run build",
 	"i18n_fcca8452fe":"集羣地址主要用於切換工作空間自動跳轉到對應的集羣",
 	"i18n_fcef976c7a":"私鑰內容",
+	"i18n_fcfbc11bb9":"密碼字段在編輯的時候不會返回,如果需要重置或者清空就請點我",
 	"i18n_fd6e80f1e0":"正常",
 	"i18n_fd7b461411":"不清空",
 	"i18n_fd7e0c997d":"選擇文件",

+ 29 - 0
web-vue/src/i18n/locales/zh_tw.json

@@ -30,6 +30,7 @@
 	"i18n_02e35447d4":"下載構建產物,如果按鈕不可用表示產物檔案不存在,一般是構建沒有產生對應的檔案或者構建歷史相關檔案被刪除",
 	"i18n_0306ea1908":"刪除映象",
 	"i18n_031020489f":"當前工作空間您觸發的構建記錄",
+	"i18n_03113c0f1a":"真的要刪除 FTP 麼?",
 	"i18n_03580275cb":"請選中要重啟的專案",
 	"i18n_0360fffb40":"並開啟此開關",
 	"i18n_036c0dc2aa":"系統取消分發",
@@ -83,6 +84,7 @@
 	"i18n_080b914139":"上傳包",
 	"i18n_0836332bf6":"升級協議",
 	"i18n_083b8a2ec9":"一個物理節點被多個服務端繫結也會產生孤獨資料奧",
+	"i18n_087c992cc0":"被動模式",
 	"i18n_08902526f1":"面板:",
 	"i18n_0895c740a6":"交換記憶體佔用",
 	"i18n_089a88ecee":"系統時間:",
@@ -136,6 +138,7 @@
 	"i18n_0ce54ecc25":"付費社群",
 	"i18n_0cf4f0ba82":"真的要儲存當前配置嗎?如果配置有誤,可能無法啟動服務需要手動還原奧!!! 儲存成功後請及時關注重啟狀態!!",
 	"i18n_0cf81d77bb":"請填寫倉庫地址",
+	"i18n_0d1ee51203":"系統關鍵詞",
 	"i18n_0d44f4903a":"真的要釋放(刪除)當前專案麼?",
 	"i18n_0d467f7889":"# 是否開啟日誌備份功能",
 	"i18n_0d48f8e881":"請輸入服務地址",
@@ -213,6 +216,7 @@
 	"i18n_143bfbc3a1":"點選重新同步當前工作空間邏輯節點專案資訊",
 	"i18n_143d8d3de5":"否則將刪除滿足條件的所有資料",
 	"i18n_148484b985":"實現您需要配置 docker 容器到服務端中來管理,並且分配到當前工作空間中",
+	"i18n_148d37218a":"FTP 名稱",
 	"i18n_1498557b2d":"同時只能展開一個選單",
 	"i18n_14a25beebb":"10秒一次",
 	"i18n_14d342362f":"標籤",
@@ -281,6 +285,7 @@
 	"i18n_1a8f90122f":"提示資訊 ",
 	"i18n_1abf39bdb6":"# 將此目錄快取到全域性(多個構建可以共享此快取目錄)",
 	"i18n_1ad696efdc":"構建執行的命令(非阻塞命令),如:mvn clean package、npm run build。支援變數:{'${BUILD_ID}'}、{'${BUILD_NAME}'}、{'${BUILD_SOURCE_FILE}'}、{'${BUILD_NUMBER_ID}'}、倉庫目錄下 .env、工作空間變數",
+	"i18n_1add83f77b":"ftp名稱",
 	"i18n_1ae2955867":"指定 pom 檔案打包 mvn -f xxx/pom.xml clean package",
 	"i18n_1afdb4a364":"隱藏滾動條。縱向滾動方式提醒:滾輪,橫行滾動方式:Shift+滾輪",
 	"i18n_1b03b0c1ff":"已經分配到工作空間的 Docker 或者叢集無法直接刪除,需要到分配到的各個工作空間逐一刪除後才能刪除資產 Docker 或者叢集",
@@ -460,6 +465,7 @@
 	"i18n_29b48a76be":"請選擇釋出方式",
 	"i18n_29efa328e5":"未分發",
 	"i18n_2a049f4f5b":"分發失敗",
+	"i18n_2a04f7b9be":"如果按鈕不可用,請去資產管理 ftp 列表的關聯中新增當前工作空間允許管理的授權資料夾",
 	"i18n_2a0bea27c4":"執行域",
 	"i18n_2a0c4740f1":"檔案",
 	"i18n_2a1d1da97a":"打包測試環境包 mvn clean package -Dmaven.test.skip=true -Ptest",
@@ -572,6 +578,7 @@
 	"i18n_32d0576d85":"的令牌",
 	"i18n_32dcc6f36e":"重啟策略:no、always、unless-stopped、on-failure",
 	"i18n_32e05f01f4":"叢集資訊",
+	"i18n_32e3c8b702":"釋出的FTP",
 	"i18n_32f882ae24":"匹配零個或多個字元",
 	"i18n_330363dfc5":"成功",
 	"i18n_3306c2a7c7":"讀取預設",
@@ -659,6 +666,7 @@
 	"i18n_3b94c70734":"專案狀態",
 	"i18n_3ba621d736":"處理成功",
 	"i18n_3baa9f3d72":"批量構建引數還支援指定引數,delay(延遲執行構建,單位秒)branchName(分支名)、branchTagName(標籤)、script(構建指令碼)、resultDirFile(構建產物)、webhook(通知webhook)",
+	"i18n_3bc3bdc031":"請到【系統管理】-> 【資產管理】-> 【FTP管理】新增FTP,或者將已新增的FTP授權關聯、分配到此工作空間",
 	"i18n_3bc5e602b2":"郵箱",
 	"i18n_3bcc1c7a20":"最後修改人",
 	"i18n_3bdab2c607":"10分鐘",
@@ -673,6 +681,7 @@
 	"i18n_3c6fa6f667":"cron表示式",
 	"i18n_3c8eada338":"請選擇編碼方式",
 	"i18n_3c91490844":"釋出操作",
+	"i18n_3c943b89c6":"此編輯僅能編輯當前 FTP 在此工作空間的名稱資訊",
 	"i18n_3c99ea4ec2":"例如 2,3,6/3中,由於“/”優先順序高,因此相當於2,3,(6/3),結果與 2,3,6等價",
 	"i18n_3c9eeee356":"真的要刪除日誌檔案麼?",
 	"i18n_3cc09369ad":"真的要刪除【",
@@ -849,6 +858,7 @@
 	"i18n_4b96762a7e":"最後修改時間",
 	"i18n_4b9c3271dc":"重置",
 	"i18n_4ba304e77a":"釘釘賬號登入",
+	"i18n_4bb37cc406":"伺服器語言",
 	"i18n_4bbc09fc55":"在檔案第 3 - 20 行中搜尋",
 	"i18n_4c096c51a3":"埠號:",
 	"i18n_4c0eead6ff":"新增引數",
@@ -867,6 +877,7 @@
 	"i18n_4d18dcbd15":"真的要還原備份資訊麼?",
 	"i18n_4d351f3c91":"禁止 IP",
 	"i18n_4d49b2a15f":"自動執行:docker",
+	"i18n_4d4ab2f8f5":"當前 FTP 的授權目錄(檔案目錄、檔案字尾)需要請到 【系統管理】-> 【資產管理】-> 【FTP 管理】-> 操作欄中->關聯按鈕->對應工作空間->操作欄中->配置按鈕",
 	"i18n_4d775d4cd7":"顯示",
 	"i18n_4d7dc6c5f8":"寫",
 	"i18n_4d85ac1250":"系統管理",
@@ -1126,6 +1137,7 @@
 	"i18n_649d90ab3c":"關閉右側",
 	"i18n_649f8046f3":"請選擇SSH節點",
 	"i18n_64c083c0a9":"結果描述",
+	"i18n_64c8791ba1":"配置方式:FTP管理->操作欄中->關聯按鈕->對應工作空間->操作欄中->配置按鈕",
 	"i18n_64eee9aafa":"開機時間",
 	"i18n_652273694e":"主機",
 	"i18n_65571516e2":"構建備註:",
@@ -1144,6 +1156,7 @@
 	"i18n_66ab5e9f24":"新增",
 	"i18n_66b71b06c6":"上傳壓縮檔案(自動解壓)",
 	"i18n_66c15f2815":"匹配包含數字的行",
+	"i18n_66e623e6f8":"配置ftp",
 	"i18n_66e9ea5488":"日誌名稱",
 	"i18n_6707667676":"主機名",
 	"i18n_6709f4548f":"隨機生成",
@@ -1329,6 +1342,7 @@
 	"i18n_77373db7d8":"接收報警訊息,非必填,GET請求",
 	"i18n_7737f088de":"批量重新啟動",
 	"i18n_773b1a5ef6":"請選擇語言模式",
+	"i18n_774aecfd99":"如果要配置 FTP 請到【系統管理】-> 【資產管理】-> 【FTP 管理】中去配置。",
 	"i18n_775fde44cf":"程序埠快取:",
 	"i18n_7760785daf":"自由指令碼",
 	"i18n_7764df7ccc":"開啟差異釋出但不開啟清空釋出時相當於只做增量和變動更新",
@@ -1406,6 +1420,7 @@
 	"i18n_7e930b95ef":"釋出檔案",
 	"i18n_7e951d56d9":"操作時間",
 	"i18n_7e9f0d2606":"專案是指,節點中的某一個專案,需要提前在節點中建立專案",
+	"i18n_7eef73a0eb":"編輯FTP",
 	"i18n_7ef30cfd31":"附加環境變數是指讀取倉庫指定環境變數檔案來新增到執行構建執行時",
 	"i18n_7f0abcf48d":"需要到編輯中去為一個節點繫結一個 ssh資訊才能啟用該功能",
 	"i18n_7f3809d36b":"構建結束",
@@ -1424,6 +1439,7 @@
 	"i18n_8086beecb3":"標籤名稱:",
 	"i18n_808c18d2bb":"值為 true 表示專案當前為執行中",
 	"i18n_809b12d6a0":"請耐心等待暫時不用重新整理頁面",
+	"i18n_80b7af89ee":"當前工作空間還沒有FTP",
 	"i18n_80cfc33cbe":"確認重置",
 	"i18n_81301b6813":"開啟終端",
 	"i18n_81485b76d8":"請輸入主機地址",
@@ -1449,6 +1465,7 @@
 	"i18n_8306971039":"所屬使用者",
 	"i18n_8309cec640":"請選擇節點專案,可能是節點中不存在任何專案,需要去節點中建立專案",
 	"i18n_833249fb92":"當前檔案用時",
+	"i18n_8339e5e8e9":"如果多選 ftp 下面目錄只顯示選項中的第一項,但是授權目錄需要保證每項都配置對應目錄",
 	"i18n_8347a927c0":"修改",
 	"i18n_835050418f":"確認要上傳最新的外掛包嗎?",
 	"i18n_8351876236":"別名 ",
@@ -1635,6 +1652,7 @@
 	"i18n_917381e4a5":"當前下載源:",
 	"i18n_91985e3574":"自動探測",
 	"i18n_91a10b8776":" 指令碼庫 ",
+	"i18n_91a828d055":"真的要清除 FTP 隱藏欄位資訊麼?(密碼)",
 	"i18n_920f05031b":"狀態描述",
 	"i18n_922b76febd":"執行模式必填",
 	"i18n_923f8d2688":"釋出後命令",
@@ -1867,6 +1885,7 @@
 	"i18n_a436c94494":"飛書掃碼",
 	"i18n_a472019766":"節點Id",
 	"i18n_a497562c8e":"執行人",
+	"i18n_a49f609d09":"主動模式",
 	"i18n_a4f5cae8d2":"開啟狀態",
 	"i18n_a4f629041c":"路徑需要配置絕對路徑",
 	"i18n_a50fbc5a52":"支援指定網絡卡名稱來繫結:",
@@ -2345,6 +2364,7 @@
 	"i18n_cd998f12fa":"當目標工作空間已經存在節點時候將自動同步節點授權資訊、代理配置資訊",
 	"i18n_cda84be2f6":"操作日誌",
 	"i18n_cdc478d90c":"系統名",
+	"i18n_cdf2e36c2a":"FTP名稱",
 	"i18n_ce043fac7d":"當前工作空間還沒有SSH",
 	"i18n_ce07501354":"點選數字檢視執行中的任務",
 	"i18n_ce1c5765e4":"檢視釋出模板",
@@ -2358,6 +2378,7 @@
 	"i18n_ceee1db95a":"容器埠",
 	"i18n_ceffe5d643":"兩步驗證應用",
 	"i18n_cf38e8f9fd":"當前為節點分發的授權路徑配置",
+	"i18n_cf93cd2cde":"請選擇FTP",
 	"i18n_cfa72dd73a":"請輸入要檢查的 cron 表示式",
 	"i18n_cfb00269fd":"執行指令碼",
 	"i18n_cfbb3341d5":"當前登入的賬號是:",
@@ -2411,6 +2432,7 @@
 	"i18n_d4aea8d7e6":"執行次數",
 	"i18n_d4e03f60a9":"外掛端啟動的時候檢查專案狀態,如果專案狀態是未執行則嘗試執行啟動專案",
 	"i18n_d5269713c7":"表示構建產物為資料夾時將打包為",
+	"i18n_d55b5f6ce4":"傳輸模式",
 	"i18n_d57796d6ac":" :範圍:0~59",
 	"i18n_d584e1493b":"搜尋ssh名稱",
 	"i18n_d58a55bcee":"關",
@@ -2433,6 +2455,7 @@
 	"i18n_d731dc9325":"時間戳:",
 	"i18n_d7471c0261":"請選擇執行節點",
 	"i18n_d75c02d050":"停止專案",
+	"i18n_d769de863b":"關聯工作空間ftp",
 	"i18n_d7ac764d3a":"分發間隔時間 (順序重啟、完整順序重啟)方式才生效",
 	"i18n_d7ba18c360":"分發節點是指在編輯完指令碼後自動將指令碼內容同步節點的指令碼中",
 	"i18n_d7bebd0e5e":"狀態操作請到控制檯中控制",
@@ -2536,6 +2559,7 @@
 	"i18n_df9d1fedc5":"節點分發是指,一個專案部署在多個節點中使用節點分發一步完成多個節點中的專案釋出操作",
 	"i18n_dfb8d511c7":"使用者名稱稱",
 	"i18n_dfcc9e3c45":"分發後操作",
+	"i18n_e020a4df74":"伺服器系統關鍵詞",
 	"i18n_e039ffccc8":"】此檔案還原到專案目錄?",
 	"i18n_e049546ff3":"【系統配置目錄】中修改",
 	"i18n_e06497b0fb":"檢視當前可用容器",
@@ -2645,9 +2669,11 @@
 	"i18n_ea8a79546f":"請輸入釋出的檔案id",
 	"i18n_ea9f824647":"拉取倉庫超時時間,單位秒",
 	"i18n_eaa5d7cb9b":"過期天數",
+	"i18n_eaa85849f3":"如果 ftp 沒有配置授權目錄是不能選擇的喲",
 	"i18n_eadd05ba6a":"中等",
 	"i18n_eaf987eea0":"權重(相對權重)。",
 	"i18n_eb164b696d":"排除釋出",
+	"i18n_eb3a60fbc0":"編輯 FTP",
 	"i18n_eb5bab1c31":"非必填",
 	"i18n_eb79cea638":"週五",
 	"i18n_eb7f9ceb71":"指令碼庫:",
@@ -2706,6 +2732,7 @@
 	"i18n_f038f48ce5":"編輯指令碼",
 	"i18n_f04a289502":"svn ssh 必填登入使用者",
 	"i18n_f05e3ec44d":"禁止訪問,當前IP限制訪問",
+	"i18n_f06a391743":"已經分配到工作空間的 FTP 無法直接刪除,需要到分配到的各個工作空間逐一刪除後才能刪除資產 FTP",
 	"i18n_f06f95f8e6":"孤獨資料",
 	"i18n_f087eb347c":"構建命令示例",
 	"i18n_f08afd1f82":"已選擇",
@@ -2807,6 +2834,7 @@
 	"i18n_fad1b9fb87":"新增指令碼模版需要到節點管理中去新增",
 	"i18n_fb1f3b5125":"當前工作空間關聯資料統計",
 	"i18n_fb3a2241bb":"狀態描述:",
+	"i18n_fb5037a644":"此配置僅對服務端管理生效, 工作空間的 ftp 配置需要單獨配置",
 	"i18n_fb5bc565f3":"解析檔案失敗:",
 	"i18n_fb61d4d708":"真的要回滾該構建歷史記錄麼?",
 	"i18n_fb7b9876a6":"請輸入指令碼名稱",
@@ -2831,6 +2859,7 @@
 	"i18n_fcbf0d0a55":"需要先安裝依賴 yarn && yarn run build",
 	"i18n_fcca8452fe":"叢集地址主要用於切換工作空間自動跳轉到對應的叢集",
 	"i18n_fcef976c7a":"私鑰內容",
+	"i18n_fcfbc11bb9":"密碼欄位在編輯的時候不會返回,如果需要重置或者清空就請點我",
 	"i18n_fd6e80f1e0":"正常",
 	"i18n_fd7b461411":"不清空",
 	"i18n_fd7e0c997d":"選擇檔案",

+ 136 - 2
web-vue/src/pages/build/edit.vue

@@ -886,6 +886,73 @@
                   </a-form-item-rest>
                 </a-form-item>
               </template>
+
+              <!-- FTP -->
+              <template v-if="temp.releaseMethod === 6">
+                <a-form-item name="releaseMethodDataId" :help="$t('i18n_eaa85849f3')">
+                  <template #label>
+                    <a-tooltip
+                      >{{ $t('i18n_32e3c8b702') }}<template #title>{{ $t('i18n_eaa85849f3') }}</template>
+                      <QuestionCircleOutlined v-if="!temp.id" />
+                    </a-tooltip>
+                  </template>
+                  <a-row>
+                    <a-col :span="22">
+                      <a-select
+                        v-model:value="tempExtraData.releaseMethodDataId_6"
+                        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
+                            )
+                          }
+                        "
+                        mode="multiple"
+                        :placeholder="$t('i18n_cf93cd2cde')"
+                      >
+                        <a-select-option v-for="ftp in ftpList" :key="ftp.id" :disabled="!ftp.fileDirs">
+                          <a-tooltip :title="ftp.name"> {{ ftp.name }}</a-tooltip>
+                        </a-select-option>
+                      </a-select>
+                    </a-col>
+                    <a-col :span="1" style="margin-left: 10px">
+                      <ReloadOutlined @click="loadFtpList" />
+                    </a-col>
+                  </a-row>
+                </a-form-item>
+                <a-form-item name="releaseMethodDataId" :help="$t('i18n_8339e5e8e9')">
+                  <template #label>
+                    <a-tooltip :title="$t('i18n_8339e5e8e9')">
+                      {{ $t('i18n_dbb2df00cf') }}
+                      <QuestionCircleOutlined v-if="!temp.id" />
+                    </a-tooltip>
+                  </template>
+                  <a-input-group compact>
+                    <a-select
+                      v-model:value="tempExtraData.releaseFtpDir"
+                      show-search
+                      allow-clear
+                      style="width: 30%"
+                      :placeholder="$t('i18n_cf93cd2cde')"
+                    >
+                      <a-select-option v-for="item in selectFtpDirs" :key="item">
+                        <a-tooltip :title="item">{{ item }}</a-tooltip>
+                      </a-select-option>
+                    </a-select>
+                    <a-form-item-rest>
+                      <a-input
+                        v-model:value="tempExtraData.releasePath3"
+                        style="width: 70%"
+                        :placeholder="$t('i18n_a75a5a9525')"
+                      />
+                    </a-form-item-rest>
+                  </a-input-group>
+                </a-form-item>
+              </template>
             </template>
           </div>
 
@@ -1266,8 +1333,8 @@
           chooseScriptVisible === 1
             ? tempExtraData.noticeScriptId
             : temp.script?.indexOf('$ref.script.') != -1
-              ? temp.script.replace('$ref.script.', '')
-              : ''
+            ? temp.script.replace('$ref.script.', '')
+            : ''
         "
         mode="choose"
         @confirm="
@@ -1384,6 +1451,7 @@ import {
   getBuildGet
 } from '@/api/build-info'
 import { getSshListAll } from '@/api/ssh'
+import { getFtpListAll } from '@/api/ftp'
 import { getRepositoryInfo } from '@/api/repository'
 import { getNodeListAll, getProjectListAll } from '@/api/node'
 // import { getScriptListAll } from "@/api/server-script";
@@ -1497,6 +1565,7 @@ export default {
       dispatchList: [],
       cascaderList: [],
       sshList: [],
+      ftpList: [],
       dockerSwarmList: [],
       //集群下 服务下拉数据
       swarmServiceListOptions: [],
@@ -1658,6 +1727,27 @@ export default {
       }
       return []
     },
+    selectFtpDirs() {
+      if (!this.ftpList || this.ftpList.length <= 0) {
+        return []
+      }
+      const findArray = this.ftpList.filter((item) => {
+        if (Array.isArray(this.tempExtraData.releaseMethodDataId_6)) {
+          return item.id === this.tempExtraData.releaseMethodDataId_6[0]
+        }
+        return item.id === this.tempExtraData.releaseMethodDataId_6
+      })
+      if (findArray.length) {
+        const fileDirs = findArray[0].fileDirs
+        if (!fileDirs) {
+          return []
+        }
+        return JSON.parse(fileDirs).map((item) => {
+          return (item + '/').replace(new RegExp('//', 'gm'), '/')
+        })
+      }
+      return []
+    },
     buildModeArray() {
       return Object.keys(this.buildModeMap).map((item) => {
         return {
@@ -1714,6 +1804,7 @@ export default {
       this.loadDispatchList()
       this.loadNodeProjectList()
       this.loadSshList()
+      this.loadFtpList()
       this.loadDockerSwarmListAll()
       // this.loadScriptListList();
 
@@ -1766,6 +1857,9 @@ export default {
         if (record.releaseMethod === 3) {
           this.tempExtraData.releaseMethodDataId_3 = this.tempExtraData.releaseMethodDataId.split(',')
         }
+        if (record.releaseMethod === 6) {
+          this.tempExtraData.releaseMethodDataId_6 = this.tempExtraData.releaseMethodDataId.split(',')
+        }
       }
       this.tempExtraData = { ...this.tempExtraData }
       this.changeRepositpry(true)
@@ -1792,6 +1886,25 @@ export default {
           }
         }
       })
+
+      this.loadFtpList().then(() => {
+        if (this.tempExtraData.releaseMethodDataId_6) {
+          //
+          const findDirs = this.selectFtpDirs
+            .filter((item) => {
+              return this.tempExtraData.releaseFtpPath && this.tempExtraData.releaseFtpPath.indexOf(item) > -1
+            })
+            .sort((item1, item2) => {
+              return item2.length - item1.length
+            })
+          const releaseFtpDir = findDirs[0] || ''
+          this.tempExtraData = {
+            ...this.tempExtraData,
+            releaseFtpDir: releaseFtpDir,
+            releasePath3: (this.tempExtraData.releaseFtpPath || '').slice(releaseFtpDir.length)
+          }
+        }
+      })
       // 默认打开构建流程
       // this.stepsCurrent = this.editSteps
     },
@@ -1884,6 +1997,7 @@ export default {
             tempExtraData.releaseMethodDataId_2_node = this.temp.releaseMethodDataIdList[0]
             tempExtraData.releaseMethodDataId_2_project = this.temp.releaseMethodDataIdList[1]
           } else if (this.temp.releaseMethod === 3) {
+            // ssh
             //  (this. tempExtraData.releasePath || '').slice(releaseSshDir.length);
             tempExtraData.releasePath = (
               (tempExtraData.releaseSshDir || '') +
@@ -1891,6 +2005,14 @@ export default {
               (tempExtraData.releasePath2 || '')
             ).replace(new RegExp('//', 'gm'), '/')
             tempExtraData.releaseMethodDataId_3 = (tempExtraData.releaseMethodDataId_3 || []).join(',')
+          } else if (this.temp.releaseMethod === 6) {
+            // ftp
+            tempExtraData.releaseFtpPath = (
+              (tempExtraData.releaseFtpDir || '') +
+              '/' +
+              (tempExtraData.releasePath3 || '')
+            ).replace(new RegExp('//', 'gm'), '/')
+            tempExtraData.releaseMethodDataId_6 = (tempExtraData.releaseMethodDataId_6 || []).join(',')
           }
 
           this.temp = {
@@ -1975,6 +2097,18 @@ export default {
         })
       })
     },
+    // 加载 FTP 列表
+    loadFtpList() {
+      return new Promise((resolve) => {
+        this.ftpList = []
+        getFtpListAll().then((res) => {
+          if (res.code === 200) {
+            this.ftpList = res.data
+            resolve()
+          }
+        })
+      })
+    },
     //
     loadDockerSwarmListAll() {
       dockerSwarmListAll().then((res) => {

+ 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>

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

@@ -0,0 +1,603 @@
+<template>
+  <div>
+    <template v-if="useSuggestions">
+      <a-result :title="$t('i18n_80b7af89ee')" :sub-title="$t('i18n_3bc3bdc031')">
+        <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="$t('i18n_1add83f77b')"
+            @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="$t('i18n_2a04f7b9be')">
+                <a-button size="small" type="primary" :disabled="true">{{ $t('i18n_2a0c4740f1') }}</a-button>
+              </a-tooltip>
+            </template>
+
+            <a-tooltip placement="topLeft" :title="$t('i18n_2a04f7b9be')">
+              <a-button size="small" type="primary" @click="handleEdit(record)">{{ $t('i18n_95b351c862') }}</a-button>
+            </a-tooltip>
+            <a-tooltip placement="topLeft" :title="$t('i18n_2a04f7b9be')">
+              <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="$t('i18n_eb3a60fbc0')"
+      :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>{{ $t('i18n_3c943b89c6') }}</li>
+                <li>{{ $t('i18n_774aecfd99') }}</li>
+                <li>
+                  {{ $t('i18n_4d4ab2f8f5') }}
+                </li>
+              </ul>
+            </template>
+          </a-alert>
+        </template>
+        <a-form-item :label="$t('i18n_148d37218a')" name="name">
+          <a-input v-model:value="temp.name" :max-length="50" :placeholder="$t('i18n_148d37218a')" />
+        </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: this.$t('i18n_c76cfefe72'),
+          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: this.$t('i18n_03113c0f1a'),
+        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>

+ 68 - 6
web-vue/src/pages/script/script-log.vue

@@ -1,17 +1,28 @@
 <template>
   <div class="">
     <!-- 数据表格 -->
-    <a-table
+    <CustomTable
+      is-show-tools
+      default-auto-refresh
+      :auto-refresh-time="30"
+      :active-page="activePage"
+      table-name="server-script-log-list"
       :data-source="list"
       size="middle"
       :columns="columns"
       :pagination="pagination"
       bordered
-      row-key="id"
+      :row-key="
+        (record) => {
+          return record.id + ':' + record.scriptId
+        }
+      "
       :scroll="{
         x: 'max-content'
       }"
+      :row-selection="rowSelection"
       @change="changePage"
+      @refresh="loadData"
     >
       <template #title>
         <a-space wrap class="search-box">
@@ -80,9 +91,17 @@
           <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"
+            danger
+            :disabled="!tableSelections || tableSelections.length <= 0"
+            @click="handleBatchDelete"
+          >
+            {{ $t('i18n_7fb62b3011') }}
+          </a-button>
         </a-space>
       </template>
-      <template #bodyCell="{ column, text, record }">
+      <template #tableBodyCell="{ column, text, record }">
         <template v-if="column.dataIndex === 'scriptName'">
           <a-tooltip placement="topLeft" :title="text">
             <span>{{ text }}</span>
@@ -122,7 +141,7 @@
           </a-space>
         </template>
       </template>
-    </a-table>
+    </CustomTable>
     <!-- 日志 -->
 
     <script-log-view
@@ -138,7 +157,7 @@
   </div>
 </template>
 <script>
-import { getScriptLogList, scriptDel, triggerExecTypeMap } from '@/api/server-script'
+import { getScriptLogList, scriptDel, triggerExecTypeMap, scriptBatchDel } from '@/api/server-script'
 import ScriptLogView from '@/pages/script/script-log-view'
 import { statusMap } from '@/api/command'
 import { CHANGE_PAGE, COMPUTED_PAGINATION, PAGE_DEFAULT_LIST_QUERY, parseTime } from '@/utils/const'
@@ -215,12 +234,25 @@ export default {
 
           width: '150px'
         }
-      ]
+      ],
+      tableSelections: []
     }
   },
   computed: {
     pagination() {
       return COMPUTED_PAGINATION(this.listQuery)
+    },
+    activePage() {
+      return this.$attrs.routerUrl === this.$route.path
+    },
+    rowSelection() {
+      return {
+        onChange: (selectedRowKeys) => {
+          this.tableSelections = selectedRowKeys
+        },
+        selectedRowKeys: this.tableSelections,
+        type: 'checkbox'
+      }
     }
   },
   mounted() {
@@ -271,6 +303,36 @@ export default {
     changePage(pagination, filters, sorter) {
       this.listQuery = CHANGE_PAGE(this.listQuery, { pagination, sorter })
       this.loadData()
+    },
+    // 批量删除
+    handleBatchDelete() {
+      if (!this.tableSelections || this.tableSelections.length <= 0) {
+        $notification.warning({
+          message: this.$t('i18n_5d817c403e')
+        })
+        return
+      }
+      $confirm({
+        title: this.$t('i18n_c4535759ee'),
+        zIndex: 1009,
+        content: '真的要删除这些脚本日志吗?',
+        okText: this.$t('i18n_e83a256e4f'),
+        cancelText: this.$t('i18n_625fb26b4b'),
+        onOk: () => {
+          // 删除
+          return scriptBatchDel({
+            ids: this.tableSelections.join(',')
+          }).then((res) => {
+            if (res.code === 200) {
+              $notification.success({
+                message: res.msg
+              })
+              this.tableSelections = []
+              this.loadData()
+            }
+          })
+        }
+      })
     }
   }
 }

+ 61 - 4
web-vue/src/pages/ssh/command-log.vue

@@ -1,6 +1,11 @@
 <template>
   <div>
-    <a-table
+    <CustomTable
+      is-show-tools
+      default-auto-refresh
+      :auto-refresh-time="30"
+      :active-page="activePage"
+      table-name="ssh-command-log-list"
       size="middle"
       :data-source="commandList"
       :columns="columns"
@@ -9,7 +14,10 @@
       :scroll="{
         x: 'max-content'
       }"
+      row-key="id"
+      :row-selection="rowSelection"
       @change="changePage"
+      @refresh="getCommandLogData"
     >
       <template #title>
         <a-space wrap class="search-box">
@@ -68,9 +76,17 @@
               $t('i18n_e5f71fc31e')
             }}</a-button>
           </a-tooltip>
+          <a-button
+            type="primary"
+            danger
+            :disabled="!tableSelections || tableSelections.length <= 0"
+            @click="handleBatchDelete"
+          >
+            {{ $t('i18n_7fb62b3011') }}
+          </a-button>
         </a-space>
       </template>
-      <template #bodyCell="{ column, text, record }">
+      <template #tableBodyCell="{ column, text, record }">
         <template v-if="column.dataIndex === 'sshName'">
           <a-tooltip placement="topLeft" :title="text">
             <span>{{ text }}</span>
@@ -106,7 +122,7 @@
           </a-space>
         </template>
       </template>
-    </a-table>
+    </CustomTable>
     <!-- 构建日志 -->
     <CustomModal
       v-if="logVisible"
@@ -204,7 +220,8 @@ export default {
           fixed: 'right',
           width: '200px'
         }
-      ]
+      ],
+      tableSelections: []
     }
   },
   computed: {
@@ -214,6 +231,18 @@ export default {
     },
     style() {
       return this.getFullscreenViewLogStyle()
+    },
+    activePage() {
+      return this.$attrs.routerUrl === this.$route.path
+    },
+    rowSelection() {
+      return {
+        onChange: (selectedRowKeys) => {
+          this.tableSelections = selectedRowKeys
+        },
+        selectedRowKeys: this.tableSelections,
+        type: 'checkbox'
+      }
     }
   },
   created() {},
@@ -263,6 +292,34 @@ export default {
         }
       })
     },
+    // 批量删除
+    handleBatchDelete() {
+      if (!this.tableSelections || this.tableSelections.length <= 0) {
+        $notification.warning({
+          message: this.$t('i18n_5d817c403e')
+        })
+        return
+      }
+      $confirm({
+        title: this.$t('i18n_c4535759ee'),
+        zIndex: 1009,
+        content: '真的要删除这些执行记录吗?',
+        okText: this.$t('i18n_e83a256e4f'),
+        cancelText: this.$t('i18n_625fb26b4b'),
+        onOk: () => {
+          // 删除
+          return deleteCommandLog(this.tableSelections.join(',')).then((res) => {
+            if (res.code === 200) {
+              $notification.success({
+                message: res.msg
+              })
+              this.tableSelections = []
+              this.getCommandLogData()
+            }
+          })
+        }
+      })
+    },
     // 下载构建日志
     handleDownload(record) {
       window.open(downloadLog(record.id), '_blank')

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

@@ -0,0 +1,952 @@
+<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="$t('i18n_1add83f77b')"
+            @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="$t('i18n_7eef73a0eb')"
+      :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="$t('i18n_cdf2e36c2a')" name="name">
+          <a-input v-model:value="temp.name" :max-length="50" :placeholder="$t('i18n_cdf2e36c2a')" />
+        </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="$t('i18n_d55b5f6ce4')" 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="$t('i18n_fcfbc11bb9')">
+                <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="$t('i18n_4bb37cc406')" name="serverLanguageCode">
+          <a-input v-model:value="temp.serverLanguageCode" :placeholder="$t('i18n_4bb37cc406')" />
+        </a-form-item>
+        <a-form-item :label="$t('i18n_0d1ee51203')" name="systemKey">
+          <a-input v-model:value="temp.systemKey" :placeholder="$t('i18n_e020a4df74')" />
+        </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
+            >{{ $t('i18n_fb5037a644') }}<span style="color: red">{{ $t('i18n_64c8791ba1') }}</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="$t('i18n_d769de863b')"
+      :footer="null"
+      :mask-closable="false"
+    >
+      <a-space direction="vertical" style="width: 100%">
+        <a-alert
+          v-if="workspaceFtpList && workspaceFtpList.length"
+          :message="$t('i18n_f06a391743')"
+          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="$t('i18n_66e623e6f8')"
+      :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="$t('i18n_148d37218a')">
+          <a-input v-model:value="temp.name" :disabled="true" :max-length="50" :placeholder="$t('i18n_148d37218a')" />
+        </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: this.$t('i18n_a49f609d09'), value: 'Active' },
+        { label: this.$t('i18n_087c992cc0'), 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: this.$t('i18n_4bb37cc406'),
+          dataIndex: 'serverLanguageCode',
+          width: 120,
+          sorter: true,
+          ellipsis: true
+        },
+        {
+          title: this.$t('i18n_e020a4df74'),
+          dataIndex: 'systemKey',
+          sorter: true,
+          width: '100px',
+          ellipsis: true
+        },
+        {
+          title: this.$t('i18n_6143a714d0'),
+          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: this.$t('i18n_91a828d055'),
+        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',