Rust+Slint 实现FTP客户端调试助手源码分享

Rust+Slint 实现FTP客户端调试助手源码分享

一、效果展示

二、源码分享

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 自动刷新界面

整个程序的核心思想可以概括为:

  1. UI 层只负责展示和收集用户输入,不直接执行网络操作。
  2. 业务逻辑层通过异步任务执行 FTP 操作,避免阻塞 UI。
  3. 事件通道解耦两层,通过轮询机制实现数据同步。
  4. 模型驱动界面刷新,数据变化自动反映到界面上。

这种架构使得程序具有良好的响应性、可维护性和可扩展性,是 Rust 桌面应用开发中一种典型的异步 UI 架构模式。

r)

相关推荐
外收内放1 小时前
面向对象高级(实战案例)
开发语言·python·学习
专业程序开发源1 小时前
springboot欧缇蔓月子中心管理系统82697-计算机课程设计、毕业设计
java·spring boot·后端·python·spring·django·课程设计
卷无止境2 小时前
面向arm64桌面客户端开发全景:语言、框架与生态
后端·python
Flynt2 小时前
Cloudflare 用 Rust 重写了 TypeScript:我拿同一批代码跑了跑,报错一模一样,退出码不一样
rust·typescript·前端工程化
fanruitian2 小时前
python else用法
开发语言·python
FRing032 小时前
JavaScript入门到精通二十六(变量提升、函数剩余参数、展开运算符、ES6箭头函数)
开发语言·前端·javascript·es6·箭头函数
云深麋鹿2 小时前
C++ | 基于红黑树实现mymap与myset
开发语言·c++
IT_陈寒2 小时前
JavaScript的闭包陷阱,这个坑我躺了三天才爬出来
前端·人工智能·后端
用户8356290780512 小时前
如何使用 Python 对 Excel 数据进行分组和分级显示
后端·python