
Rust+Slint 实现FTP客户端调试助手源码分享
- 一、效果展示
- 二、源码分享
- 三、实现原理
-
- 1、整体架构
- [2、UI 与业务逻辑的解耦设计](#2、UI 与业务逻辑的解耦设计)
-
- [2.1 事件驱动的通信机制](#2.1 事件驱动的通信机制)
- [2.2 共享状态的线程安全](#2.2 共享状态的线程安全)
- [3、FTP 连接管理](#3、FTP 连接管理)
-
- [3.1 连接与认证](#3.1 连接与认证)
- [3.2 断开连接](#3.2 断开连接)
- 4、命令收发机制
-
- [4.1 原始命令发送](#4.1 原始命令发送)
- [4.2 命令日志的双向展示](#4.2 命令日志的双向展示)
- 5、文件列表的获取与展示
-
- [5.1 目录列表解析](#5.1 目录列表解析)
- [5.2 文件大小格式化](#5.2 文件大小格式化)
- 6、文件传输实现
-
- [6.1 文件下载](#6.1 文件下载)
- [6.2 文件上传](#6.2 文件上传)
- 7、目录导航
-
- [7.1 进入子目录](#7.1 进入子目录)
- [7.2 返回上级目录](#7.2 返回上级目录)
- [8、Slint 界面与 Rust 的绑定](#8、Slint 界面与 Rust 的绑定)
-
- [8.1 回调绑定](#8.1 回调绑定)
- [8.2 模型驱动界面](#8.2 模型驱动界面)
- [8.3 跨线程 UI 更新](#8.3 跨线程 UI 更新)
- 9、事件轮询机制
- 10、整体流程总结
一、效果展示


二、源码分享
1、工程结构

2、main.rs
rust
mod ftp_client;
use std::rc::Rc;
use std::sync::mpsc;
use std::time::Duration;
use chrono::Local;
use slint::{Model, SharedString, VecModel};
use ftp_client::{FtpClient, FtpClientEvent, FtpClientEventType, RemoteFileListing};
slint::include_modules!();
struct ClientAppState {
files_model: Rc<VecModel<RemoteFileEntry>>,
logs_model: Rc<VecModel<CommandLogEntry>>,
current_path: String,
file_listings: Vec<RemoteFileListing>,
}
fn main() {
let ui = FtpClientWindow::new().expect("failed to create UI");
let (event_tx, event_rx) = mpsc::channel::<FtpClientEvent>();
let ftp_client = std::sync::Arc::new(tokio::sync::Mutex::new(FtpClient::new(event_tx.clone())));
let rt = tokio::runtime::Runtime::new().expect("failed to create tokio runtime");
// ─── 创建模型 ────────────────────────────────────
let files_model = Rc::new(VecModel::<RemoteFileEntry>::default());
ui.set_remote_files(slint::ModelRc::from(files_model.clone()));
let logs_model = Rc::new(VecModel::<CommandLogEntry>::default());
ui.set_command_logs(slint::ModelRc::from(logs_model.clone()));
let state = std::sync::Arc::new(std::sync::Mutex::new(ClientAppState {
files_model: files_model.clone(),
logs_model: logs_model.clone(),
current_path: "/".to_string(),
file_listings: Vec::new(),
}));
// ─── 初始日志 ────────────────────────────────────
add_log(&logs_model, 2, "ℹ", "FTP 客户端调试助手已启动");
add_log(&logs_model, 2, "ℹ", "请输入服务器信息后点击\"连接\"");
ui.set_log_count_text(SharedString::from(format!("{} 条", logs_model.row_count())));
// ─── 连接 ────────────────────────────────────────
let client_clone = ftp_client.clone();
let rt_handle = rt.handle().clone();
let weak_ui = ui.as_weak();
let state_clone = state.clone();
ui.on_connect_clicked(move || {
let client = client_clone.clone();
let rt = rt_handle.clone();
let weak = weak_ui.clone();
let st = state_clone.clone();
let (host, port, user, pass) = if let Some(ui) = weak.upgrade() {
(
ui.get_host().to_string(),
ui.get_port_text().parse::<u16>().unwrap_or(21),
ui.get_username().to_string(),
ui.get_password().to_string(),
)
} else {
return;
};
add_log(&st.lock().unwrap().logs_model, 2, "ℹ", &format!("正在连接 {}:{}...", host, port));
rt.spawn(async move {
let mut c = client.lock().await;
match c.connect(&host, port, &user, &pass).await {
Ok(_) => {
slint::invoke_from_event_loop(move || {
if let Some(ui) = weak.upgrade() {
ui.set_is_connected(true);
}
}).ok();
}
Err(e) => {
slint::invoke_from_event_loop(move || {
if let Some(ui) = weak.upgrade() {
ui.set_is_connected(false);
ui.set_connection_status(SharedString::from(format!("连接失败: {}", e)));
}
}).ok();
}
}
});
});
// ─── 断开 ────────────────────────────────────────
let client_clone = ftp_client.clone();
let rt_handle = rt.handle().clone();
let weak_ui = ui.as_weak();
ui.on_disconnect_clicked(move || {
let client = client_clone.clone();
let rt = rt_handle.clone();
let weak = weak_ui.clone();
rt.spawn(async move {
let mut c = client.lock().await;
c.disconnect().await;
slint::invoke_from_event_loop(move || {
if let Some(ui) = weak.upgrade() {
ui.set_is_connected(false);
}
}).ok();
});
});
// ─── 发送命令 ────────────────────────────────────
let client_clone = ftp_client.clone();
let rt_handle = rt.handle().clone();
let weak_ui = ui.as_weak();
ui.on_send_command(move || {
let client = client_clone.clone();
let rt = rt_handle.clone();
let weak = weak_ui.clone();
let cmd = if let Some(ui) = weak.upgrade() {
let cmd = ui.get_command_input().to_string();
if cmd.trim().is_empty() {
return;
}
ui.set_command_input(SharedString::from(""));
cmd
} else {
return;
};
rt.spawn(async move {
let mut c = client.lock().await;
if let Err(e) = c.send_raw_command(&cmd).await {
let _ = weak.upgrade();
let _ = e; // 错误通过事件通道返回
}
});
});
// ─── 快捷命令 ────────────────────────────────────
let client_clone = ftp_client.clone();
let rt_handle = rt.handle().clone();
ui.on_quick_command(move |cmd| {
let client = client_clone.clone();
let rt = rt_handle.clone();
let cmd_str = cmd.to_string();
rt.spawn(async move {
let mut c = client.lock().await;
let _ = c.send_raw_command(&cmd_str).await;
});
});
// ─── 文件点击 ────────────────────────────────────
let client_clone = ftp_client.clone();
let rt_handle = rt.handle().clone();
let weak_ui = ui.as_weak();
let state_clone = state.clone();
ui.on_remote_file_clicked(move |idx| {
if let Some(ui) = weak_ui.upgrade() {
ui.set_selected_file_index(idx);
let st = state_clone.lock().unwrap();
if let Some(listing) = st.file_listings.get(idx as usize) {
if listing.is_dir {
let dir_name = listing.name.clone();
let current = st.current_path.clone();
let new_path = if current == "/" {
format!("/{}", dir_name)
} else {
format!("{}/{}", current.trim_end_matches('/'), dir_name)
};
drop(st);
ui.set_current_remote_path(SharedString::from(&new_path));
let client = client_clone.clone();
let rt = rt_handle.clone();
let weak = weak_ui.clone();
let _state2 = state_clone.clone();
rt.spawn(async move {
let mut c = client.lock().await;
let _ = c.cwd(&new_path).await;
slint::invoke_from_event_loop(move || {
if let Some(_ui) = weak.upgrade() {
// path updated via event
}
}).ok();
});
}
}
}
});
// ─── 返回上级 ────────────────────────────────────
let client_clone = ftp_client.clone();
let rt_handle = rt.handle().clone();
let weak_ui = ui.as_weak();
let state_clone = state.clone();
ui.on_navigate_up(move || {
if let Some(ui) = weak_ui.upgrade() {
let current = ui.get_current_remote_path().to_string();
if current != "/" {
let new_path = if let Some(pos) = current.rfind('/') {
if pos == 0 { "/".to_string() } else { current[..pos].to_string() }
} else {
"/".to_string()
};
ui.set_current_remote_path(SharedString::from(&new_path));
state_clone.lock().unwrap().current_path = new_path.clone();
let client = client_clone.clone();
let rt = rt_handle.clone();
rt.spawn(async move {
let mut c = client.lock().await;
let _ = c.cwd(&new_path).await;
});
}
}
});
// ─── 刷新文件 ────────────────────────────────────
let client_clone = ftp_client.clone();
let rt_handle = rt.handle().clone();
let _weak_ui = ui.as_weak();
ui.on_refresh_files(move || {
let client = client_clone.clone();
let rt = rt_handle.clone();
rt.spawn(async move {
let mut c = client.lock().await;
let _ = c.list_dir(None).await;
});
});
// ─── 下载文件 ────────────────────────────────────
let client_clone = ftp_client.clone();
let rt_handle = rt.handle().clone();
let weak_ui = ui.as_weak();
let state_clone = state.clone();
ui.on_download_file(move || {
if let Some(ui) = weak_ui.upgrade() {
let idx = ui.get_selected_file_index();
let st = state_clone.lock().unwrap();
if let Some(listing) = st.file_listings.get(idx as usize) {
if !listing.is_dir {
let remote_name = listing.name.clone();
let remote_path = format!("{}/{}", st.current_path.trim_end_matches('/'), &remote_name);
let local_path = remote_name.clone();
let client = client_clone.clone();
let rt = rt_handle.clone();
rt.spawn(async move {
let mut c = client.lock().await;
let _ = c.download(&remote_path, &local_path).await;
});
}
}
}
});
// ─── 上传文件 ────────────────────────────────────
let client_clone = ftp_client.clone();
let rt_handle = rt.handle().clone();
let weak_ui = ui.as_weak();
let state_clone = state.clone();
ui.on_upload_file(move || {
if let Some(ui) = weak_ui.upgrade() {
let st = state_clone.lock().unwrap();
let current_dir = st.current_path.clone();
// 上传本地当前目录下的文件(与选中文件同名),
// 如果没有选中文件,尝试上传 "upload.txt"
let idx = ui.get_selected_file_index();
let remote_name = if idx >= 0 {
st.file_listings.get(idx as usize)
.filter(|l| !l.is_dir)
.map(|l| l.name.clone())
.unwrap_or_else(|| "upload.txt".to_string())
} else {
"upload.txt".to_string()
};
let local_path = remote_name.clone();
let remote_path = format!("{}/{}", current_dir.trim_end_matches('/'), &remote_name);
drop(st);
add_log(&state_clone.lock().unwrap().logs_model, 2, "ℹ",
&format!("上传本地文件 {} -> {}", local_path, remote_path));
let client = client_clone.clone();
let rt = rt_handle.clone();
rt.spawn(async move {
let mut c = client.lock().await;
let _ = c.upload(&local_path, &remote_path).await;
});
}
});
// ─── 删除文件 ────────────────────────────────────
let client_clone = ftp_client.clone();
let rt_handle = rt.handle().clone();
let weak_ui = ui.as_weak();
let state_clone = state.clone();
ui.on_delete_file(move || {
if let Some(ui) = weak_ui.upgrade() {
let idx = ui.get_selected_file_index();
let st = state_clone.lock().unwrap();
if let Some(listing) = st.file_listings.get(idx as usize) {
let name = listing.name.clone();
let is_dir = listing.is_dir;
let path = format!("{}/{}", st.current_path.trim_end_matches('/'), &name);
drop(st);
let client = client_clone.clone();
let rt = rt_handle.clone();
rt.spawn(async move {
let mut c = client.lock().await;
if is_dir {
let _ = c.send_raw_command(&format!("RMD {}", path)).await;
} else {
let _ = c.delete_file(&path).await;
}
});
}
}
});
// ─── 清除日志 ────────────────────────────────────
let state_clone = state.clone();
let weak_ui = ui.as_weak();
ui.on_clear_command_logs(move || {
let st = state_clone.lock().unwrap();
st.logs_model.set_vec(vec![]);
if let Some(ui) = weak_ui.upgrade() {
ui.set_log_count_text(SharedString::from("0 条"));
}
});
// ─── 事件轮询定时器 ──────────────────────────────
let weak_ui = ui.as_weak();
let state_clone = state.clone();
let timer = slint::Timer::default();
timer.start(
slint::TimerMode::Repeated,
Duration::from_millis(100),
move || {
if let Some(ui) = weak_ui.upgrade() {
let mut st = state_clone.lock().unwrap();
while let Ok(event) = event_rx.try_recv() {
match event.event_type {
FtpClientEventType::Connected { addr } => {
ui.set_is_connected(true);
ui.set_connection_status(SharedString::from(format!("已连接到 {}", addr)));
}
FtpClientEventType::Disconnected => {
ui.set_is_connected(false);
ui.set_connection_status(SharedString::from("未连接"));
st.files_model.set_vec(vec![]);
st.file_listings.clear();
ui.set_file_count_text(SharedString::from("0 个项目"));
}
FtpClientEventType::CommandSent { text } => {
add_log(&st.logs_model, 0, "→", &text);
ui.set_log_count_text(SharedString::from(
format!("{} 条", st.logs_model.row_count()),
));
}
FtpClientEventType::Response { text } => {
add_log(&st.logs_model, 1, "←", &text);
ui.set_log_count_text(SharedString::from(
format!("{} 条", st.logs_model.row_count()),
));
}
FtpClientEventType::DirectoryListed { path, entries } => {
let file_entries: Vec<RemoteFileEntry> = entries.iter().map(|e| {
RemoteFileEntry {
name: SharedString::from(&e.name),
is_dir: e.is_dir,
size: format_size(e.size),
modified: SharedString::from(&e.modified),
}
}).collect();
st.files_model.set_vec(file_entries);
st.file_listings = entries;
st.current_path = path.clone();
ui.set_current_remote_path(SharedString::from(&path));
ui.set_file_count_text(SharedString::from(
format!("{} 个项目", st.files_model.row_count()),
));
ui.set_selected_file_index(-1);
}
FtpClientEventType::Info { level, message } => {
add_log(&st.logs_model, 2, "ℹ", &format!("[{}] {}", level, message));
ui.set_log_count_text(SharedString::from(
format!("{} 条", st.logs_model.row_count()),
));
}
}
}
}
},
);
std::mem::forget(timer);
ui.run().expect("failed to run UI");
}
// ─── 辅助函数 ────────────────────────────────────────────
fn format_size(size: u64) -> SharedString {
if size < 1024 {
SharedString::from(format!("{} B", size))
} else if size < 1024 * 1024 {
SharedString::from(format!("{:.1} KB", size as f64 / 1024.0))
} else if size < 1024 * 1024 * 1024 {
SharedString::from(format!("{:.1} MB", size as f64 / (1024.0 * 1024.0)))
} else {
SharedString::from(format!("{:.1} GB", size as f64 / (1024.0 * 1024.0 * 1024.0)))
}
}
fn add_log(model: &Rc<VecModel<CommandLogEntry>>, direction: i32, dir_text: &str, content: &str) {
let now = Local::now().format("%H:%M:%S").to_string();
model.push(CommandLogEntry {
timestamp: SharedString::from(now),
direction,
direction_text: SharedString::from(dir_text),
content: SharedString::from(content),
});
}
3、ftp_client.rs
rust
use std::sync::mpsc::Sender;
use suppaftp::tokio::AsyncFtpStream;
use suppaftp::Status;
// ─── 事件类型 ────────────────────────────────────────────
#[derive(Clone, Debug)]
pub struct FtpClientEvent {
pub event_type: FtpClientEventType,
}
#[derive(Clone, Debug)]
pub enum FtpClientEventType {
Connected { addr: String },
Disconnected,
Response { text: String },
CommandSent { text: String },
DirectoryListed { path: String, entries: Vec<RemoteFileListing> },
Info { level: String, message: String },
}
#[derive(Clone, Debug)]
pub struct RemoteFileListing {
pub name: String,
pub is_dir: bool,
pub size: u64,
pub modified: String,
}
// ─── 辅助函数 ────────────────────────────────────────────
fn send(sender: &Sender<FtpClientEvent>, event_type: FtpClientEventType) {
let _ = sender.send(FtpClientEvent { event_type });
}
// ─── FTP 客户端 ─────────────────────────────────────────
pub struct FtpClient {
ftp: Option<AsyncFtpStream>,
event_sender: Sender<FtpClientEvent>,
}
impl FtpClient {
pub fn new(event_sender: Sender<FtpClientEvent>) -> Self {
Self {
ftp: None,
event_sender,
}
}
pub fn is_connected(&self) -> bool {
self.ftp.is_some()
}
pub async fn connect(
&mut self,
host: &str,
port: u16,
user: &str,
pass: &str,
) -> Result<(), String> {
let addr = format!("{}:{}", host, port);
let mut ftp = AsyncFtpStream::connect(&addr)
.await
.map_err(|e| format!("连接失败: {}", e))?;
ftp.login(user, pass)
.await
.map_err(|e| format!("登录失败: {}", e))?;
send(&self.event_sender, FtpClientEventType::Connected { addr: addr.clone() });
send(&self.event_sender, FtpClientEventType::Info {
level: "INFO".to_string(),
message: format!("已连接到 {}", addr),
});
self.ftp = Some(ftp);
// 连接后自动列出根目录
self.list_dir(Some("/")).await.ok();
Ok(())
}
pub async fn disconnect(&mut self) {
if let Some(mut ftp) = self.ftp.take() {
let _ = ftp.quit().await;
}
send(&self.event_sender, FtpClientEventType::Disconnected);
send(&self.event_sender, FtpClientEventType::Info {
level: "INFO".to_string(),
message: "已断开连接".to_string(),
});
}
pub async fn list_dir(&mut self, path: Option<&str>) -> Result<(), String> {
let mut ftp = self.ftp.take().ok_or("未连接")?;
let tx = self.event_sender.clone();
let list_result = ftp.list(path).await;
let list = match list_result {
Ok(l) => l,
Err(e) => {
self.ftp = Some(ftp);
return Err(e.to_string());
}
};
let mut entries = Vec::new();
for line in &list {
if let Some(listing) = parse_list_line(line) {
entries.push(listing);
}
}
entries.sort_by(|a, b| match (a.is_dir, b.is_dir) {
(true, false) => std::cmp::Ordering::Less,
(false, true) => std::cmp::Ordering::Greater,
_ => a.name.to_lowercase().cmp(&b.name.to_lowercase()),
});
let display_path = path.unwrap_or("/").to_string();
send(&tx, FtpClientEventType::DirectoryListed {
path: display_path,
entries,
});
// 放回 ftp
self.ftp = Some(ftp);
Ok(())
}
pub async fn cwd(&mut self, path: &str) -> Result<(), String> {
let mut ftp = self.ftp.take().ok_or("未连接")?;
let result = ftp.cwd(path).await.map_err(|e| e.to_string());
self.ftp = Some(ftp);
result?;
self.list_dir(Some(path)).await.ok();
Ok(())
}
pub async fn send_raw_command(&mut self, cmd: &str) -> Result<(), String> {
let tx = self.event_sender.clone();
send(&tx, FtpClientEventType::CommandSent { text: cmd.to_string() });
let parts: Vec<&str> = cmd.split_whitespace().collect();
if parts.is_empty() {
return Err("空命令".to_string());
}
let command = parts[0].to_uppercase();
let mut ftp = self.ftp.take().ok_or("未连接")?;
let success_codes = &[
Status::CommandOk,
Status::System,
Status::Directory,
Status::File,
Status::Help,
Status::Name,
Status::RequestedFileActionOk,
Status::PathCreated,
Status::Closing,
];
// 标记:命令执行后是否需要刷新列表
let mut needs_relist = false;
let mut relist_path: Option<String> = None;
let result = match command.as_str() {
"LIST" | "NLST" => {
let path = parts.get(1).copied();
let list = if command == "LIST" {
ftp.list(path).await.map_err(|e| e.to_string())
} else {
ftp.nlst(path).await.map_err(|e| e.to_string())
};
match list {
Ok(lines) => {
for line in &lines {
send(&tx, FtpClientEventType::Response { text: line.clone() });
}
needs_relist = true;
relist_path = path.map(|s| s.to_string());
Ok(())
}
Err(e) => Err(e),
}
}
"CWD" => {
let path = parts.get(1).ok_or("CWD 需要路径参数");
match path {
Ok(p) => {
match ftp.cwd(p).await.map_err(|e| e.to_string()) {
Ok(_) => {
send(&tx, FtpClientEventType::Response {
text: format!("250 CWD OK: {}", p),
});
needs_relist = true;
relist_path = Some(p.to_string());
Ok(())
}
Err(e) => Err(e),
}
}
Err(e) => Err(e.to_string()),
}
}
"CDUP" => {
match ftp.cdup().await.map_err(|e| e.to_string()) {
Ok(_) => {
send(&tx, FtpClientEventType::Response {
text: "200 CDUP OK".to_string(),
});
needs_relist = true;
Ok(())
}
Err(e) => Err(e),
}
}
"PWD" => {
match ftp.pwd().await.map_err(|e| e.to_string()) {
Ok(pwd) => {
send(&tx, FtpClientEventType::Response { text: pwd });
Ok(())
}
Err(e) => Err(e),
}
}
"DELE" => {
let path = parts.get(1).ok_or("DELE 需要文件路径");
match path {
Ok(p) => {
match ftp.rm(p).await.map_err(|e| e.to_string()) {
Ok(_) => {
send(&tx, FtpClientEventType::Response {
text: format!("250 DELE OK: {}", p),
});
needs_relist = true;
Ok(())
}
Err(e) => Err(e),
}
}
Err(e) => Err(e.to_string()),
}
}
"MKD" => {
let path = parts.get(1).ok_or("MKD 需要目录名");
match path {
Ok(p) => {
match ftp.mkdir(p).await.map_err(|e| e.to_string()) {
Ok(_) => {
send(&tx, FtpClientEventType::Response {
text: format!("257 MKD OK: {}", p),
});
needs_relist = true;
Ok(())
}
Err(e) => Err(e),
}
}
Err(e) => Err(e.to_string()),
}
}
"RMD" => {
let path = parts.get(1).ok_or("RMD 需要目录名");
match path {
Ok(p) => {
match ftp.rmdir(p).await.map_err(|e| e.to_string()) {
Ok(_) => {
send(&tx, FtpClientEventType::Response {
text: format!("250 RMD OK: {}", p),
});
needs_relist = true;
Ok(())
}
Err(e) => Err(e),
}
}
Err(e) => Err(e.to_string()),
}
}
"RNFR" | "RENAME" => {
let from = parts.get(1);
let to = parts.get(2);
match (from, to) {
(Some(f), Some(t)) => {
match ftp.rename(*f, *t).await.map_err(|e| e.to_string()) {
Ok(_) => {
send(&tx, FtpClientEventType::Response {
text: format!("250 RENAME OK: {} -> {}", f, t),
});
needs_relist = true;
Ok(())
}
Err(e) => Err(e),
}
}
_ => Err("RENAME 需要两个参数: 源路径 目标路径".to_string()),
}
}
"SIZE" => {
let path = parts.get(1).ok_or("SIZE 需要文件路径");
match path {
Ok(p) => {
match ftp.size(p).await.map_err(|e| e.to_string()) {
Ok(size) => {
send(&tx, FtpClientEventType::Response {
text: format!("213 {}", size),
});
Ok(())
}
Err(e) => Err(e),
}
}
Err(e) => Err(e.to_string()),
}
}
"NOOP" => {
match ftp.noop().await.map_err(|e| e.to_string()) {
Ok(_) => {
send(&tx, FtpClientEventType::Response {
text: "200 NOOP OK".to_string(),
});
Ok(())
}
Err(e) => Err(e),
}
}
"QUIT" => {
match ftp.quit().await.map_err(|e| e.to_string()) {
Ok(_) => {
send(&tx, FtpClientEventType::Disconnected);
// ftp 不放回了,因为已经断开
return Ok(());
}
Err(e) => Err(e),
}
}
"FEAT" => {
match ftp.feat().await.map_err(|e| e.to_string()) {
Ok(feat) => {
send(&tx, FtpClientEventType::Response {
text: format!("{:?}", feat),
});
Ok(())
}
Err(e) => Err(e),
}
}
"STOR" => {
// STOR <remote_path> [local_path]
// 如果不指定 local_path,则使用 remote_path 的文件名作为本地路径
let remote_path = parts.get(1).ok_or("STOR 需要远程文件路径");
match remote_path {
Ok(rp) => {
let local_path = parts.get(2).copied().unwrap_or(rp);
send(&tx, FtpClientEventType::Info {
level: "INFO".to_string(),
message: format!("上传: {} -> {}", local_path, rp),
});
let mut file = match tokio::fs::File::open(local_path).await {
Ok(f) => f,
Err(e) => {
send(&tx, FtpClientEventType::Info {
level: "ERROR".to_string(),
message: format!("打开本地文件失败: {}", e),
});
self.ftp = Some(ftp);
return Err(format!("打开本地文件失败: {}", e));
}
};
match ftp.put_file(rp, &mut file).await {
Ok(bytes) => {
send(&tx, FtpClientEventType::Response {
text: format!("226 STOR OK: {} ({} bytes)", rp, bytes),
});
needs_relist = true;
Ok(())
}
Err(e) => {
send(&tx, FtpClientEventType::Info {
level: "ERROR".to_string(),
message: format!("上传失败: {}", e),
});
Err(e.to_string())
}
}
}
Err(e) => Err(e.to_string()),
}
}
"RETR" => {
// RETR <remote_path> [local_path]
let remote_path = parts.get(1).ok_or("RETR 需要远程文件路径");
match remote_path {
Ok(rp) => {
let local_path = parts.get(2).copied().unwrap_or(rp);
send(&tx, FtpClientEventType::Info {
level: "INFO".to_string(),
message: format!("下载: {} -> {}", rp, local_path),
});
let result: Result<(), String> = async {
let mut file = tokio::fs::File::create(local_path).await
.map_err(|e| format!("创建本地文件失败: {}", e))?;
let mut reader = ftp.retr_as_stream(rp).await
.map_err(|e| format!("下载失败: {}", e))?;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
let mut buf = [0u8; 8192];
loop {
let n = reader.read(&mut buf).await
.map_err(|e| format!("读取数据失败: {}", e))?;
if n == 0 { break; }
file.write_all(&buf[..n]).await
.map_err(|e| format!("写入文件失败: {}", e))?;
}
ftp.finalize_retr_stream(reader).await
.map_err(|e| format!("完成传输失败: {}", e))?;
Ok(())
}.await;
match &result {
Ok(_) => {
send(&tx, FtpClientEventType::Response {
text: format!("226 RETR OK: {}", rp),
});
}
Err(e) => {
send(&tx, FtpClientEventType::Info {
level: "ERROR".to_string(),
message: e.clone(),
});
}
}
result
}
Err(e) => Err(e.to_string()),
}
}
_ => {
// 通用命令 - 使用 custom_command
match ftp.custom_command(cmd, success_codes).await.map_err(|e| e.to_string()) {
Ok(response) => {
send(&tx, FtpClientEventType::Response {
text: format!("{}", response),
});
Ok(())
}
Err(e) => Err(e),
}
}
};
// 放回 ftp(QUIT 除外)
self.ftp = Some(ftp);
let cmd_result = result;
// 需要刷新列表时调用 list_dir
if needs_relist {
self.list_dir(relist_path.as_deref()).await.ok();
}
cmd_result
}
pub async fn download(&mut self, remote_path: &str, local_path: &str) -> Result<(), String> {
let tx = self.event_sender.clone();
send(&tx, FtpClientEventType::Info {
level: "INFO".to_string(),
message: format!("开始下载: {} -> {}", remote_path, local_path),
});
let mut ftp = self.ftp.take().ok_or("未连接")?;
let result: Result<(), String> = async {
let mut file = tokio::fs::File::create(local_path).await
.map_err(|e| format!("创建本地文件失败: {}", e))?;
let mut reader = ftp.retr_as_stream(remote_path).await
.map_err(|e| format!("下载失败: {}", e))?;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
let mut buf = [0u8; 8192];
loop {
let n = reader.read(&mut buf).await
.map_err(|e| format!("读取数据失败: {}", e))?;
if n == 0 { break; }
file.write_all(&buf[..n]).await
.map_err(|e| format!("写入文件失败: {}", e))?;
}
ftp.finalize_retr_stream(reader).await
.map_err(|e| format!("完成传输失败: {}", e))?;
Ok(())
}.await;
self.ftp = Some(ftp);
match &result {
Ok(_) => {
send(&tx, FtpClientEventType::Info {
level: "INFO".to_string(),
message: format!("下载完成: {}", remote_path),
});
}
Err(e) => {
send(&tx, FtpClientEventType::Info {
level: "ERROR".to_string(),
message: e.clone(),
});
}
}
result
}
pub async fn upload(&mut self, local_path: &str, remote_path: &str) -> Result<(), String> {
let tx = self.event_sender.clone();
send(&tx, FtpClientEventType::Info {
level: "INFO".to_string(),
message: format!("开始上传: {} -> {}", local_path, remote_path),
});
let mut ftp = self.ftp.take().ok_or("未连接")?;
let mut file = match tokio::fs::File::open(local_path).await {
Ok(f) => f,
Err(e) => {
self.ftp = Some(ftp);
return Err(format!("打开本地文件失败: {}", e));
}
};
let result = ftp
.put_file(remote_path, &mut file)
.await
.map_err(|e| format!("上传失败: {}", e));
self.ftp = Some(ftp);
match &result {
Ok(bytes) => {
send(&tx, FtpClientEventType::Info {
level: "INFO".to_string(),
message: format!("上传完成: {} ({} bytes)", remote_path, bytes),
});
}
Err(e) => {
send(&tx, FtpClientEventType::Info {
level: "ERROR".to_string(),
message: e.clone(),
});
}
}
result.map(|_| ())
}
pub async fn delete_file(&mut self, path: &str) -> Result<(), String> {
let mut ftp = self.ftp.take().ok_or("未连接")?;
let result = ftp.rm(path).await.map_err(|e| e.to_string());
self.ftp = Some(ftp);
if result.is_ok() {
send(&self.event_sender, FtpClientEventType::Info {
level: "INFO".to_string(),
message: format!("已删除: {}", path),
});
self.list_dir(None).await.ok();
}
result
}
}
// ─── LIST 输出解析 ──────────────────────────────────────
fn parse_list_line(line: &str) -> Option<RemoteFileListing> {
let line = line.trim();
if line.is_empty() {
return None;
}
let parts: Vec<&str> = line.split_whitespace().collect();
if parts.len() < 9 {
return Some(RemoteFileListing {
name: line.to_string(),
is_dir: false,
size: 0,
modified: String::new(),
});
}
let permissions = parts[0];
let is_dir = permissions.starts_with('d');
let size = parts[4].parse::<u64>().unwrap_or(0);
let modified = format!("{} {} {}", parts[5], parts[6], parts[7]);
let name = parts[8..].join(" ");
Some(RemoteFileListing {
name,
is_dir,
size,
modified,
})
}
4、main.slint
rust
export struct RemoteFileEntry {
name: string,
is-dir: bool,
size: string,
modified: string,
}
export struct CommandLogEntry {
timestamp: string,
direction: int,
direction-text: string,
content: string,
}
// ─── 可复用组件 ──────────────────────────────────────────
component NavButton inherits Rectangle {
in property <string> label;
in property <length> label-size: 14px;
in property <color> base-bg: #1a1a2e;
in property <color> hover-bg: #2a2a42;
in property <color> press-bg: #3a3a55;
callback clicked();
width: 30px; height: 30px; border-radius: 6px;
border-color: #2a2a42; border-width: 1px;
background: touch.pressed ? press-bg : touch.has-hover ? hover-bg : base-bg;
animate background { duration: 120ms; }
touch := TouchArea {
mouse-cursor: pointer;
clicked => { root.clicked(); }
}
Text {
text: label; color: #ffffffaa; font-size: label-size;
horizontal-alignment: center; vertical-alignment: center;
}
}
component ActionButton inherits Rectangle {
in property <string> label;
in property <length> label-size: 14px;
in property <bool> enabled: true;
in property <color> active-bg;
in property <color> active-hover: active-bg;
in property <color> active-press: active-bg;
in property <color> active-text;
in property <color> active-border;
in property <color> disabled-bg: #1a1a2e;
in property <color> disabled-text: #ffffff44;
callback clicked();
width: 30px; height: 30px; border-radius: 6px;
background: enabled
? (touch.pressed ? active-press : touch.has-hover ? active-hover : active-bg)
: disabled-bg;
border-color: enabled ? active-border : #2a2a42; border-width: 1px;
animate background { duration: 120ms; }
touch := TouchArea {
mouse-cursor: enabled ? pointer : default;
clicked => { if enabled { root.clicked(); } }
}
Text {
text: label;
color: enabled ? active-text : disabled-text;
font-size: label-size;
horizontal-alignment: center; vertical-alignment: center;
}
}
component QuickCmdButton inherits Rectangle {
in property <string> label;
in property <color> text-color: #90caf9;
in property <color> base-bg: #1a1a2e;
in property <color> hover-bg: #2a2a42;
in property <color> press-bg: #3a3a55;
callback clicked();
horizontal-stretch: 1; height: 26px;
border-radius: 4px;
border-color: #2a2a42; border-width: 1px;
background: touch.pressed ? press-bg : touch.has-hover ? hover-bg : base-bg;
animate background { duration: 120ms; }
touch := TouchArea {
mouse-cursor: pointer;
clicked => { root.clicked(); }
}
Text {
text: label; color: text-color; font-size: 10px;
font-weight: 600; font-family: "monospace";
horizontal-alignment: center; vertical-alignment: center;
}
}
// ─── 主窗口 ──────────────────────────────────────────────
export component FtpClientWindow inherits Window {
title: "FTP 客户端调试助手";
min-width: 1100px;
min-height: 750px;
background: #0d0d1a;
// ─── 属性 ──────────────────────────────────────────
in-out property <string> host: "127.0.0.1";
in-out property <string> port-text: "2121";
in-out property <string> username: "admin";
in-out property <string> password: "123456";
in property <bool> is-connected: false;
in property <string> connection-status: "未连接";
in property <string> current-remote-path: "/";
in-out property <string> command-input: "";
in property <[RemoteFileEntry]> remote-files;
in property <[CommandLogEntry]> command-logs;
in property <int> selected-file-index: -1;
in property <string> file-count-text: "0 个项目";
in property <string> log-count-text: "0 条";
// ─── 回调 ──────────────────────────────────────────
callback connect-clicked();
callback disconnect-clicked();
callback send-command();
callback quick-command(string);
callback remote-file-clicked(int);
callback navigate-up();
callback refresh-files();
callback download-file();
callback upload-file();
callback delete-file();
callback clear-command-logs();
VerticalLayout {
// ═══ 标题栏 ═══════════════════════════════════════
Rectangle {
background: #0a0a14;
height: 44px;
HorizontalLayout {
alignment: space-between;
padding-left: 16px;
padding-right: 8px;
HorizontalLayout {
alignment: start;
spacing: 12px;
Rectangle {
y:parent.height/2 - self.height/2;
width: 28px; height: 28px; border-radius: 6px;
background: #1e88e5;
Text {
text: "FTP"; color: white; font-size: 11px;
font-weight: 700; horizontal-alignment: center;
vertical-alignment: center;
}
}
Text {
text: "FTP 客户端调试助手";
color: #ffffffcc; font-size: 14px; font-weight: 600;
vertical-alignment: center;
}
}
HorizontalLayout {
alignment: end; spacing: 4px;
Rectangle {y:parent.height/2 - self.height/2; width: 14px; height: 14px; border-radius: 7px; background: #ff5f57; }
Rectangle {y:parent.height/2 - self.height/2; width: 14px; height: 14px; border-radius: 7px; background: #ffbd2e; }
Rectangle {y:parent.height/2 - self.height/2; width: 14px; height: 14px; border-radius: 7px; background: #28c840; }
}
}
}
// ═══ 连接栏 ═══════════════════════════════════════
Rectangle {
background: #12121f;
height: 56px;
HorizontalLayout {
padding-left: 16px;
padding-right: 16px;
spacing: 12px;
alignment: start;
// Host
HorizontalLayout {
spacing: 4px; alignment: start;
Text {
text: "主机:"; color: #ffffff88; font-size: 12px;
vertical-alignment: center; min-width: 35px;
}
Rectangle {
y:parent.height/2 - self.height/2;
width: 130px; height: 30px; border-radius: 6px;
background: #0d0d18; border-color: #2a2a42; border-width: 1px;
clip: true;
HorizontalLayout {
padding-left: 10px;
TextInput {
text: root.host;
color: #ffffffdd; font-size: 13px;
font-family: "monospace"; vertical-alignment: center;
horizontal-alignment: left;
edited => { root.host = self.text; }
}
}
}
}
// Port
HorizontalLayout {
spacing: 4px; alignment: start;
Text {
text: "端口:"; color: #ffffff88; font-size: 12px;
vertical-alignment: center; min-width: 35px;
}
Rectangle {
y:parent.height/2 - self.height/2;
width: 70px; height: 30px; border-radius: 6px;
background: #0d0d18; border-color: #2a2a42; border-width: 1px;
clip: true;
HorizontalLayout {
padding-left: 10px;
TextInput {
text: root.port-text;
color: #ffffffdd; font-size: 13px;
font-family: "monospace"; vertical-alignment: center;
horizontal-alignment: left;
edited => { root.port-text = self.text; }
}
}
}
}
// Username
HorizontalLayout {
spacing: 4px; alignment: start;
Text {
text: "用户:"; color: #ffffff88; font-size: 12px;
vertical-alignment: center; min-width: 35px;
}
Rectangle {
y:parent.height/2 - self.height/2;
width: 100px; height: 30px; border-radius: 6px;
background: #0d0d18; border-color: #2a2a42; border-width: 1px;
clip: true;
HorizontalLayout {
padding-left: 10px;
TextInput {
text: root.username;
color: #ffffffdd; font-size: 13px;
font-family: "monospace"; vertical-alignment: center;
horizontal-alignment: left;
edited => { root.username = self.text; }
}
}
}
}
// Password
HorizontalLayout {
spacing: 4px; alignment: start;
Text {
text: "密码:"; color: #ffffff88; font-size: 12px;
vertical-alignment: center; min-width: 35px;
}
Rectangle {
y:parent.height/2 - self.height/2;
width: 100px; height: 30px; border-radius: 6px;
background: #0d0d18; border-color: #2a2a42; border-width: 1px;
clip: true;
HorizontalLayout {
padding-left: 10px;
TextInput {
text: root.password;
color: #ffffffdd; font-size: 13px;
font-family: "monospace"; vertical-alignment: center;
horizontal-alignment: left;
edited => { root.password = self.text; }
}
}
}
}
// Connect / Disconnect button
Rectangle {
y:parent.height/2 - self.height/2;
width: 90px; height: 32px; border-radius: 6px;
background: root.is-connected
? (conn-touch.pressed ? #d32f2f : conn-touch.has-hover ? #e53935 : #c62828)
: (conn-touch.pressed ? #43a047 : conn-touch.has-hover ? #388e3c : #2e7d32);
animate background { duration: 120ms; }
conn-touch := TouchArea {
mouse-cursor: pointer;
clicked => {
if root.is-connected {
root.disconnect-clicked();
} else {
root.connect-clicked();
}
}
}
HorizontalLayout {
alignment: center; spacing: 6px;
Rectangle {
y:parent.height/2 - self.height/2;
width: 8px; height: 8px; border-radius: 4px;
background: root.is-connected ? #4caf50 : #f44336;
}
Text {
text: root.is-connected ? "断开" : "连接";
color: white; font-size: 13px; font-weight: 600;
vertical-alignment: center;
}
}
}
// Status
HorizontalLayout {
spacing: 6px; alignment: start;
Rectangle {
y:parent.height/2 - self.height/2;
width: 8px; height: 8px; border-radius: 4px;
background: root.is-connected ? #4caf50 : #f44336;
}
Text {
text: root.connection-status;
color: root.is-connected ? #81c784 : #ffffff66;
font-size: 12px; vertical-alignment: center;
}
}
}
}
// ═══ 主内容区 ═════════════════════════════════════
Rectangle {
vertical-stretch: 1;
min-height: 0;
HorizontalLayout {
spacing: 0px;
// ─── 左侧:远程文件浏览器 ────────────────
Rectangle {
horizontal-stretch: 1;
background: #0d0d1a;
VerticalLayout {
spacing: 0px;
// 面板标题
Rectangle {
height: 40px; background: #0e0e1a;
HorizontalLayout {
padding-left: 14px; padding-right: 14px;
spacing: 8px; alignment: space-between;
HorizontalLayout {
spacing: 8px; alignment: start;
Text { text: "📂"; font-size: 14px; vertical-alignment: center; }
Text {
text: "远程文件"; color: #ffffffbb;
font-size: 13px; font-weight: 600;
vertical-alignment: center;
}
}
Text {
text: root.file-count-text;
color: #ffffff44; font-size: 11px;
vertical-alignment: center;
}
}
}
// 路径导航栏
Rectangle {
height: 42px; background: #0f0f1c;
border-color: #1e1e32; border-width: 1px;
HorizontalLayout {
padding-left: 14px; padding-right: 14px;
spacing: 8px; alignment: space-between;
// 左侧:导航按钮 + 路径
HorizontalLayout {
spacing: 6px; alignment: start;
NavButton {
y:parent.height/2 - self.height/2;
label: "⬆";
clicked => { root.navigate-up(); }
}
NavButton {
y:parent.height/2 - self.height/2;
label: "🔄"; label-size: 13px;
clicked => { root.refresh-files(); }
}
Rectangle {
y:parent.height/2 - self.height/2;
min-width: 30px; height: 30px; border-radius: 6px;
background: #0a0a14; border-color: #2a2a42; border-width: 1px;
horizontal-stretch: 1;
HorizontalLayout {
padding-left: 10px; padding-right: 10px;
Text {
text: root.current-remote-path;
color: #ffffffaa; font-size: 12px;
font-family: "monospace"; vertical-alignment: center;
}
}
}
}
// 右侧:操作按钮
HorizontalLayout {
spacing: 6px; alignment: end;
ActionButton {
y:parent.height/2 - self.height/2;
label: "⬇";
enabled: root.is-connected;
active-bg: #1565c033; active-hover: #1565c055; active-press: #1565c077;
active-text: #90caf9; active-border: #1565c055;
clicked => { root.download-file(); }
}
ActionButton {
y:parent.height/2 - self.height/2;
label: "⬆";
enabled: root.is-connected;
active-bg: #2e7d3233; active-hover: #2e7d3255; active-press: #2e7d3277;
active-text: #81c784; active-border: #2e7d3255;
clicked => { root.upload-file(); }
}
ActionButton {
y:parent.height/2 - self.height/2;
label: "🗑"; label-size: 13px;
enabled: root.is-connected;
active-bg: #b71c1c22; active-hover: #b71c1c44; active-press: #b71c1c66;
active-text: #ef9a9a; active-border: #b71c1c44;
clicked => { root.delete-file(); }
}
}
}
}
// 文件列表表头
Rectangle {
height: 34px; background: #0e0e1a;
HorizontalLayout {
padding-left: 12px; padding-right: 12px; spacing: 10px;
Rectangle { width: 24px; }
Text { text: "名称"; color: #ffffff55; font-size: 11px; font-weight: 600; vertical-alignment: center; horizontal-stretch: 1; }
Text { text: "大小"; color: #ffffff55; font-size: 11px; font-weight: 600; vertical-alignment: center; min-width: 80px; horizontal-alignment: right; }
Text { text: "修改时间"; color: #ffffff55; font-size: 11px; font-weight: 600; vertical-alignment: center; min-width: 120px; horizontal-alignment: right; }
}
}
// 文件列表
Rectangle {
vertical-stretch: 1; clip: true;
VerticalLayout {
for idx in root.remote-files.length : Rectangle {
height: 36px;
background: file-touch.pressed ? #2a2a42
: file-touch.has-hover ? #1e1e35
: idx == root.selected-file-index ? #1a1a2e
: transparent;
border-radius: 6px;
animate background { duration: 120ms; }
file-touch := TouchArea {
mouse-cursor: pointer;
clicked => { root.remote-file-clicked(idx); }
}
HorizontalLayout {
padding-left: 12px; padding-right: 12px; spacing: 10px;
Rectangle {
width: 24px; height: 24px; border-radius: 4px;
background: root.remote-files[idx].is-dir ? #ff980033 : #2196f322;
Text {
text: root.remote-files[idx].is-dir ? "📁" : "📄";
font-size: 14px; horizontal-alignment: center;
vertical-alignment: center;
}
}
Text {
text: root.remote-files[idx].name;
color: root.remote-files[idx].is-dir ? #ffcc80 : #ffffffcc;
font-size: 13px;
font-weight: root.remote-files[idx].is-dir ? 500 : 400;
vertical-alignment: center;
horizontal-alignment: left;
horizontal-stretch: 1;
overflow: elide;
}
Text {
text: root.remote-files[idx].is-dir ? "--" : root.remote-files[idx].size;
color: #ffffff66; font-size: 12px;
vertical-alignment: center;
horizontal-alignment: right;
min-width: 80px;
}
Text {
text: root.remote-files[idx].modified;
color: #ffffff44; font-size: 11px;
vertical-alignment: center;
horizontal-alignment: right;
min-width: 120px;
}
}
}
}
}
}
}
// ─── 右侧:命令面板 ──────────────────────
Rectangle {
width: 400px; background: #0f0f1c;
border-color: #1e1e32; border-width: 1px;
VerticalLayout {
spacing: 0px;
// 面板标题
Rectangle {
height: 40px; background: #0e0e1a;
HorizontalLayout {
padding-left: 14px; padding-right: 14px;
spacing: 8px; alignment: space-between;
HorizontalLayout {
spacing: 8px; alignment: start;
Text { text: "💻"; font-size: 14px; vertical-alignment: center; }
Text {
text: "命令面板"; color: #ffffffbb;
font-size: 13px; font-weight: 600;
vertical-alignment: center;
}
}
Text {
text: root.log-count-text;
color: #ffffff44; font-size: 11px;
vertical-alignment: center;
}
}
}
// 命令输入区
Rectangle {
height: 50px; background: #12121f;
border-color: #1e1e32; border-width: 1px;
HorizontalLayout {
padding-left: 12px; padding-right: 12px;
spacing: 8px;
Rectangle {
horizontal-stretch: 1; height: 32px;
border-radius: 6px; background: #0a0a14;
border-color: #2a2a42; border-width: 1px;
clip: true;
HorizontalLayout {
padding-left: 10px;
TextInput {
text: root.command-input;
color: #ffffffdd; font-size: 13px;
font-family: "monospace";
vertical-alignment: center;
horizontal-alignment: left;
edited => { root.command-input = self.text; }
accepted => { root.send-command(); }
}
}
}
Rectangle {
width: 60px; height: 32px; border-radius: 6px;
background: root.is-connected
? (send-touch.pressed ? #1565c0 : send-touch.has-hover ? #1976d2 : #1e88e5)
: (send-touch.pressed ? #455a64 : send-touch.has-hover ? #546e7a : #37474f);
animate background { duration: 120ms; }
send-touch := TouchArea {
mouse-cursor: pointer;
clicked => { root.send-command(); }
}
Text {
text: "发送";
color: white; font-size: 13px; font-weight: 600;
horizontal-alignment: center;
vertical-alignment: center;
}
}
}
}
// 快捷命令按钮
Rectangle {
height: 80px; background: #0e0e1a;
border-color: #1e1e32; border-width: 1px;
VerticalLayout {
padding: 8px; spacing: 6px;
// 第一行
HorizontalLayout {
spacing: 6px;
for cmd in ["LIST", "PWD", "CWD /", "STAT", "SYST", "TYPE I"] : QuickCmdButton {
label: cmd;
clicked => { root.quick-command(cmd); }
}
}
// 第二行
HorizontalLayout {
spacing: 6px;
for cmd in ["DELE", "MKD", "RMD", "NOOP", "FEAT", "QUIT"] : QuickCmdButton {
label: cmd;
text-color: #ffb74d;
clicked => { root.quick-command(cmd); }
}
}
}
}
// 响应日志
Rectangle {
vertical-stretch: 1; background: #0a0a14;
clip: true;
VerticalLayout {
// 日志头
Rectangle {
height: 30px; background: #0e0e1a;
HorizontalLayout {
padding-left: 12px; padding-right: 12px;
alignment: space-between;
Text {
text: "响应日志";
color: #ffffff88; font-size: 11px;
font-weight: 600; vertical-alignment: center;
}
Rectangle {
width: 50px; height: 22px; border-radius: 4px;
background: clear-touch.pressed ? #b71c1c66 : clear-touch.has-hover ? #b71c1c55 : #b71c1c33;
animate background { duration: 120ms; }
clear-touch := TouchArea {
mouse-cursor: pointer;
clicked => { root.clear-command-logs(); }
}
Text {
text: "清除"; color: #ef9a9a; font-size: 10px;
font-weight: 500; horizontal-alignment: center;
vertical-alignment: center;
}
}
}
}
// 日志列表
Rectangle {
vertical-stretch: 1; clip: true;
VerticalLayout {
for idx in root.command-logs.length : Rectangle {
height: 26px;
HorizontalLayout {
padding-left: 12px; padding-right: 12px;
spacing: 8px;
alignment: start;
Text {
text: root.command-logs[idx].timestamp;
color: #ffffff44; font-size: 10px;
font-family: "monospace";
vertical-alignment: center;
min-width: 70px;
}
Rectangle {
width: 20px; height: 16px;
border-radius: 3px;
background: root.command-logs[idx].direction == 0 ? #1565c044
: root.command-logs[idx].direction == 1 ? #1b5e2044
: #e6510044;
Text {
text: root.command-logs[idx].direction-text;
color: root.command-logs[idx].direction == 0 ? #90caf9
: root.command-logs[idx].direction == 1 ? #81c784
: #ffb74d;
font-size: 10px; font-weight: 700;
horizontal-alignment: center;
vertical-alignment: center;
}
}
Text {
text: root.command-logs[idx].content;
color: root.command-logs[idx].direction == 0 ? #90caf9
: root.command-logs[idx].direction == 1 ? #ffffffaa
: #ffb74d;
font-size: 11px; font-family: "monospace";
vertical-alignment: center;
overflow: elide;
}
}
}
}
}
}
}
}
}
}
}
}
}
5、build.rs
rust
fn main() {
slint_build::compile("ui/main.slint").unwrap();
}
6、Cargo.toml
rust
[package]
name = "ftp-client"
version = "0.1.0"
edition = "2024"
[dependencies]
slint = "1.17.1"
tokio = { version = "1", features = ["full"] }
chrono = "0.4"
suppaftp = { version = ">=10.0.2", features = ["tokio"] }
[build-dependencies]
slint-build = "1.17.1"
7、ftp_server_test.py(测试使用)
python
"""
FTP 服务器测试例程
用于测试 FTP 客户端调试助手
"""
import os
import sys
import threading
from pyftpdlib.authorizers import DummyAuthorizer
from pyftpdlib.handlers import FTPHandler
from pyftpdlib.servers import FTPServer
class SimpleFTPServer:
"""简单的 FTP 服务器"""
def __init__(self, host="127.0.0.1", port=2121, directory="./ftp_root",
username="admin", password="123456"):
self.host = host
self.port = port
self.directory = directory
self.username = username
self.password = password
self.server = None
self.running = False
# 确保目录存在
os.makedirs(directory, exist_ok=True)
# 创建一些测试文件
self._create_test_files()
def _create_test_files(self):
"""创建测试文件"""
test_files = [
("test1.txt", "这是测试文件 1"),
("test2.txt", "这是测试文件 2"),
("readme.txt", "FTP 服务器测试文件\n用于测试客户端功能"),
]
for filename, content in test_files:
filepath = os.path.join(self.directory, filename)
if not os.path.exists(filepath):
with open(filepath, 'w', encoding='utf-8') as f:
f.write(content)
print(f"✓ 创建测试文件: {filename}")
# 创建子目录
subdir = os.path.join(self.directory, "subdir")
os.makedirs(subdir, exist_ok=True)
if not os.path.exists(os.path.join(subdir, "test3.txt")):
with open(os.path.join(subdir, "test3.txt"), 'w', encoding='utf-8') as f:
f.write("这是子目录中的测试文件")
print(f"✓ 创建子目录和文件: subdir/test3.txt")
def start(self):
"""启动 FTP 服务器"""
try:
# 创建授权器
authorizer = DummyAuthorizer()
# 添加用户(完整权限)
authorizer.add_user(
self.username,
self.password,
self.directory,
perm="elradfmw"
)
# 添加匿名用户(只读)
authorizer.add_anonymous(self.directory, perm="elr")
# 创建 FTP 处理器
handler = FTPHandler
handler.authorizer = authorizer
# 设置被动模式端口范围
handler.passive_ports = range(60000, 60100)
# 创建服务器
self.server = FTPServer((self.host, self.port), handler)
self.server.max_cons = 256
self.server.max_cons_per_ip = 5
self.running = True
print(f"\n{'='*60}")
print(f"FTP 服务器已启动")
print(f"{'='*60}")
print(f"主机: {self.host}")
print(f"端口: {self.port}")
print(f"用户名: {self.username}")
print(f"密码: {self.password}")
print(f"根目录: {self.directory}")
print(f"{'='*60}")
print(f"\n按 Ctrl+C 停止服务器\n")
# 启动服务器
self.server.serve_forever()
except KeyboardInterrupt:
print("\n\n正在停止服务器...")
self.stop()
except Exception as e:
print(f"服务器错误: {e}")
self.running = False
def stop(self):
"""停止 FTP 服务器"""
if self.server:
self.server.close_all()
self.running = False
print("✓ 服务器已停止")
def main():
"""主函数"""
print("\n" + "="*60)
print("FTP 服务器测试例程")
print("="*60)
# 默认配置
host = "127.0.0.1"
port = 2121
directory = r"D:\user\01417804\桌面\PythonProject\image"
username = "admin"
password = "123456"
print("\n服务器配置:")
print(f" 主机: {host} (回车使用默认值)")
print(f" 端口: {port} (回车使用默认值)")
print(f" 目录: {directory} (回车使用默认值)")
print(f" 用户名: {username} (回车使用默认值)")
print(f" 密码: {password} (回车使用默认值)")
print()
# 获取用户输入
host = input("主机地址: ").strip() or host
port = int(input("端口号: ").strip() or port)
directory = input("根目录: ").strip() or directory
username = input("用户名: ").strip() or username
password = input("密码: ").strip() or password
# 创建并启动服务器
server = SimpleFTPServer(host, port, directory, username, password)
server.start()
if __name__ == "__main__":
main()
!在这里插入图片描述(https://i-blog.csdnimg.cn/direct/c17b07b0666f442caabb6af40de2c2ce.pn
三、实现原理
1、整体架构
本工具采用 Rust + Slint + Tokio + suppaftp 的技术栈,整体架构分为三层:
- UI 层(Slint):负责界面渲染与用户交互,包括文件列表、命令面板、日志输出等。
- 业务逻辑层(Rust):负责 FTP 连接管理、命令收发、文件操作等核心逻辑。
- 异步运行时层(Tokio):为 FTP 网络操作提供异步执行环境,避免阻塞 UI 线程。
三层之间通过 事件通道(mpsc channel) 进行解耦通信,UI 层不直接执行网络操作,而是通过回调将任务交给异步运行时,再通过事件通道接收结果并更新界面。
2、UI 与业务逻辑的解耦设计
2.1 事件驱动的通信机制
程序的核心设计思路是 事件驱动 。UI 层与业务逻辑层之间通过 mpsc::channel 建立了一条单向事件通道:
rust
let (event_tx, event_rx) = mpsc::channel::<FtpClientEvent>();
- UI 层:用户点击按钮时,仅将操作请求发送给异步任务,不直接等待结果。
- 业务层 :FTP 操作完成后,通过
event_tx发送事件(如连接成功、目录列表、命令响应等)。 - UI 层轮询 :Slint 的
Timer每 100ms 轮询一次event_rx,取出事件并更新界面。
这种设计的好处是:
- UI 线程不会被网络 I/O 阻塞,界面始终保持流畅。
- 业务逻辑与界面渲染完全解耦,便于维护和扩展。
2.2 共享状态的线程安全
由于 UI 层和异步任务运行在不同线程,共享状态通过 Arc<Mutex<T>> 保护:
rust
let state = std::sync::Arc::new(std::sync::Mutex::new(ClientAppState {
files_model: files_model.clone(),
logs_model: logs_model.clone(),
current_path: "/".to_string(),
file_listings: Vec::new(),
}));
files_model和logs_model是 Slint 的VecModel,用于驱动界面上的文件列表和日志列表。current_path记录当前远程目录路径。file_listings缓存当前目录的文件列表,供点击、下载、删除等操作使用。
3、FTP 连接管理
3.1 连接与认证
FTP 客户端基于 suppaftp 库的异步版本 AsyncFtpStream 实现。连接流程如下:
rust
pub async fn connect(&mut self, host: &str, port: u16, user: &str, pass: &str) -> Result<(), String> {
// 1. 建立 TCP 连接
let mut ftp = AsyncFtpStream::connect((host, port)).await
.map_err(|e| format!("连接失败: {}", e))?;
// 2. 登录认证
ftp.login(user, pass).await
.map_err(|e| format!("登录失败: {}", e))?;
// 3. 切换到被动模式(PASV)
ftp.mode(Mode::Passive).await
.map_err(|e| format!("设置被动模式失败: {}", e))?;
// 4. 发送事件通知 UI
self.event_tx.send(FtpClientEvent {
event_type: FtpClientEventType::Connected {
addr: format!("{}:{}", host, port),
},
}).ok();
self.ftp = Some(ftp);
Ok(())
}
关键点:
- 被动模式:客户端主动发起数据连接,避免防火墙拦截。
- 事件通知:连接成功后立即发送事件,UI 层收到后更新连接状态。
3.2 断开连接
断开时发送 QUIT 命令并关闭连接:
rust
pub async fn disconnect(&mut self) {
if let Some(ftp) = &mut self.ftp {
let _ = ftp.quit().await;
}
self.ftp = None;
self.event_tx.send(FtpClientEvent {
event_type: FtpClientEventType::Disconnected,
}).ok();
}
4、命令收发机制
4.1 原始命令发送
用户可以在命令面板输入任意 FTP 命令,程序通过 send_raw_command 发送:
rust
pub async fn send_raw_command(&mut self, cmd: &str) -> Result<(), String> {
let ftp = self.ftp.as_mut().ok_or("未连接")?;
// 发送命令事件(UI 显示 "→" 方向)
self.event_tx.send(FtpClientEvent {
event_type: FtpClientEventType::CommandSent {
text: cmd.to_string(),
},
}).ok();
// 执行命令并获取响应
let response = ftp.send_command(cmd).await
.map_err(|e| format!("命令执行失败: {}", e))?;
// 发送响应事件(UI 显示 "←" 方向)
self.event_tx.send(FtpClientEvent {
event_type: FtpClientEventType::Response {
text: response.to_string(),
},
}).ok();
Ok(())
}
4.2 命令日志的双向展示
日志模型 CommandLogEntry 通过 direction 字段区分命令方向:
direction = 0:发送的命令(蓝色 "→" 标记)。direction = 1:服务器的响应(绿色 "←" 标记)。direction = 2:系统提示信息(橙色 "ℹ" 标记)。
UI 层根据 direction 值渲染不同的颜色和图标,让用户一目了然地看到命令与响应的对应关系。
5、文件列表的获取与展示
5.1 目录列表解析
使用 suppaftp 的 list 方法获取目录列表,并解析为结构化数据:
rust
pub async fn list_dir(&mut self, path: Option<&str>) -> Result<(), String> {
let ftp = self.ftp.as_mut().ok_or("未连接")?;
// 切换到指定目录(如果有)
if let Some(p) = path {
ftp.cwd(p).await.map_err(|e| format!("切换目录失败: {}", e))?;
}
// 获取当前目录列表
let listing = ftp.list(None).await
.map_err(|e| format!("获取目录列表失败: {}", e))?;
// 解析为结构化数据
let entries: Vec<RemoteFileListing> = listing.iter().map(|line| {
parse_listing_line(line)
}).collect();
// 发送目录列表事件
self.event_tx.send(FtpClientEvent {
event_type: FtpClientEventType::DirectoryListed {
path: current_path.clone(),
entries,
},
}).ok();
Ok(())
}
5.2 文件大小格式化
文件大小通过 format_size 函数转换为人类可读的格式:
rust
fn format_size(size: u64) -> String {
if size < 1024 {
format!("{} B", size)
} else if size < 1024 * 1024 {
format!("{:.1} KB", size as f64 / 1024.0)
} else if size < 1024 * 1024 * 1024 {
format!("{:.1} MB", size as f64 / (1024.0 * 1024.0))
} else {
format!("{:.1} GB", size as f64 / (1024.0 * 1024.0 * 1024.0))
}
}
6、文件传输实现
6.1 文件下载
下载文件时,程序在异步任务中执行:
rust
pub async fn download(&mut self, remote_path: &str, local_path: &str) -> Result<(), String> {
let ftp = self.ftp.as_mut().ok_or("未连接")?;
// 发送命令日志
self.event_tx.send(FtpClientEvent {
event_type: FtpClientEventType::CommandSent {
text: format!("RETR {}", remote_path),
},
}).ok();
// 执行下载
let mut reader = ftp.retr_as_stream(remote_path).await
.map_err(|e| format!("下载失败: {}", e))?;
// 写入本地文件
let mut file = tokio::fs::File::create(local_path).await
.map_err(|e| format!("创建本地文件失败: {}", e))?;
tokio::io::copy(&mut reader, &mut file).await
.map_err(|e| format!("写入文件失败: {}", e))?;
// 发送响应日志
self.event_tx.send(FtpClientEvent {
event_type: FtpClientEventType::Response {
text: format!("下载完成: {}", local_path),
},
}).ok();
Ok(())
}
6.2 文件上传
上传流程与下载对称,使用 stor_as_stream 方法:
rust
pub async fn upload(&mut self, local_path: &str, remote_path: &str) -> Result<(), String> {
let ftp = self.ftp.as_mut().ok_or("未连接")?;
// 打开本地文件
let mut file = tokio::fs::File::open(local_path).await
.map_err(|e| format!("打开本地文件失败: {}", e))?;
// 发送命令日志
self.event_tx.send(FtpClientEvent {
event_type: FtpClientEventType::CommandSent {
text: format!("STOR {}", remote_path),
},
}).ok();
// 执行上传
let mut writer = ftp.stor_as_stream(remote_path).await
.map_err(|e| format!("上传失败: {}", e))?;
tokio::io::copy(&mut file, &mut writer).await
.map_err(|e| format!("写入远程文件失败: {}", e))?;
writer.finish().await
.map_err(|e| format!("完成上传失败: {}", e))?;
// 发送响应日志
self.event_tx.send(FtpClientEvent {
event_type: FtpClientEventType::Response {
text: format!("上传完成: {}", remote_path),
},
}).ok();
Ok(())
}
7、目录导航
7.1 进入子目录
用户双击目录项时,程序拼接新路径并执行 CWD 命令:
rust
let new_path = if current == "/" {
format!("/{}", dir_name)
} else {
format!("{}/{}", current.trim_end_matches('/'), dir_name)
};
7.2 返回上级目录
通过字符串截取实现路径回退:
rust
let new_path = if let Some(pos) = current.rfind('/') {
if pos == 0 { "/".to_string() } else { current[..pos].to_string() }
} else {
"/".to_string()
};
8、Slint 界面与 Rust 的绑定
8.1 回调绑定
Slint 界面通过 ui.on_xxx_clicked 系列方法绑定 Rust 回调:
rust
ui.on_connect_clicked(move || {
// 读取输入框内容
// 启动异步任务
});
8.2 模型驱动界面
文件列表和日志列表通过 VecModel 驱动:
rust
let files_model = Rc::new(VecModel::<RemoteFileEntry>::default());
ui.set_remote_files(slint::ModelRc::from(files_model.clone()));
当异步任务获取到新数据时,通过 set_vec 更新模型,Slint 自动刷新界面。
8.3 跨线程 UI 更新
由于异步任务运行在 Tokio 运行时中,不能直接操作 UI,必须通过 slint::invoke_from_event_loop 将 UI 更新操作调度回主线程:
rust
slint::invoke_from_event_loop(move || {
if let Some(ui) = weak.upgrade() {
ui.set_is_connected(true);
}
}).ok();
9、事件轮询机制
程序使用 Slint 的 Timer 实现事件轮询:
rust
let timer = slint::Timer::default();
timer.start(
slint::TimerMode::Repeated,
Duration::from_millis(100),
move || {
if let Some(ui) = weak_ui.upgrade() {
let mut st = state_clone.lock().unwrap();
while let Ok(event) = event_rx.try_recv() {
match event.event_type {
// 处理各类事件...
}
}
}
}
);
每 100ms 检查一次事件通道,批量处理积压的事件。这种设计避免了频繁的线程切换,同时保证了 UI 的实时响应。
10、整体流程总结
#mermaid-svg-p7lslMS8r5Od2y0L{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-p7lslMS8r5Od2y0L .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-p7lslMS8r5Od2y0L .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-p7lslMS8r5Od2y0L .error-icon{fill:#552222;}#mermaid-svg-p7lslMS8r5Od2y0L .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-p7lslMS8r5Od2y0L .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-p7lslMS8r5Od2y0L .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-p7lslMS8r5Od2y0L .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-p7lslMS8r5Od2y0L .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-p7lslMS8r5Od2y0L .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-p7lslMS8r5Od2y0L .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-p7lslMS8r5Od2y0L .marker{fill:#333333;stroke:#333333;}#mermaid-svg-p7lslMS8r5Od2y0L .marker.cross{stroke:#333333;}#mermaid-svg-p7lslMS8r5Od2y0L svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-p7lslMS8r5Od2y0L p{margin:0;}#mermaid-svg-p7lslMS8r5Od2y0L .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-p7lslMS8r5Od2y0L .cluster-label text{fill:#333;}#mermaid-svg-p7lslMS8r5Od2y0L .cluster-label span{color:#333;}#mermaid-svg-p7lslMS8r5Od2y0L .cluster-label span p{background-color:transparent;}#mermaid-svg-p7lslMS8r5Od2y0L .label text,#mermaid-svg-p7lslMS8r5Od2y0L span{fill:#333;color:#333;}#mermaid-svg-p7lslMS8r5Od2y0L .node rect,#mermaid-svg-p7lslMS8r5Od2y0L .node circle,#mermaid-svg-p7lslMS8r5Od2y0L .node ellipse,#mermaid-svg-p7lslMS8r5Od2y0L .node polygon,#mermaid-svg-p7lslMS8r5Od2y0L .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-p7lslMS8r5Od2y0L .rough-node .label text,#mermaid-svg-p7lslMS8r5Od2y0L .node .label text,#mermaid-svg-p7lslMS8r5Od2y0L .image-shape .label,#mermaid-svg-p7lslMS8r5Od2y0L .icon-shape .label{text-anchor:middle;}#mermaid-svg-p7lslMS8r5Od2y0L .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-p7lslMS8r5Od2y0L .rough-node .label,#mermaid-svg-p7lslMS8r5Od2y0L .node .label,#mermaid-svg-p7lslMS8r5Od2y0L .image-shape .label,#mermaid-svg-p7lslMS8r5Od2y0L .icon-shape .label{text-align:center;}#mermaid-svg-p7lslMS8r5Od2y0L .node.clickable{cursor:pointer;}#mermaid-svg-p7lslMS8r5Od2y0L .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-p7lslMS8r5Od2y0L .arrowheadPath{fill:#333333;}#mermaid-svg-p7lslMS8r5Od2y0L .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-p7lslMS8r5Od2y0L .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-p7lslMS8r5Od2y0L .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-p7lslMS8r5Od2y0L .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-p7lslMS8r5Od2y0L .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-p7lslMS8r5Od2y0L .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-p7lslMS8r5Od2y0L .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-p7lslMS8r5Od2y0L .cluster text{fill:#333;}#mermaid-svg-p7lslMS8r5Od2y0L .cluster span{color:#333;}#mermaid-svg-p7lslMS8r5Od2y0L div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-p7lslMS8r5Od2y0L .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-p7lslMS8r5Od2y0L rect.text{fill:none;stroke-width:0;}#mermaid-svg-p7lslMS8r5Od2y0L .icon-shape,#mermaid-svg-p7lslMS8r5Od2y0L .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-p7lslMS8r5Od2y0L .icon-shape p,#mermaid-svg-p7lslMS8r5Od2y0L .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-p7lslMS8r5Od2y0L .icon-shape rect,#mermaid-svg-p7lslMS8r5Od2y0L .image-shape rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-p7lslMS8r5Od2y0L .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-p7lslMS8r5Od2y0L .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-p7lslMS8r5Od2y0L :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 用户操作 UI
Slint 回调触发
Tokio 异步任务
suppaftp 执行 FTP 操作
发送事件到 mpsc 通道
Timer 每 100ms 轮询
更新 VecModel
Slint 自动刷新界面
整个程序的核心思想可以概括为:
- UI 层只负责展示和收集用户输入,不直接执行网络操作。
- 业务逻辑层通过异步任务执行 FTP 操作,避免阻塞 UI。
- 事件通道解耦两层,通过轮询机制实现数据同步。
- 模型驱动界面刷新,数据变化自动反映到界面上。
这种架构使得程序具有良好的响应性、可维护性和可扩展性,是 Rust 桌面应用开发中一种典型的异步 UI 架构模式。

r)