当前位置: 代码网 > it编程>前端脚本>Python > Python基于PyQt实现FTP客户端调试助手

Python基于PyQt实现FTP客户端调试助手

2026年10月10日 • Python •我要评论
一、效果展示二、源码分享1、工程结构2、库安装3、main.py"""功能: - 连接/断开 ftp 服务器 - 本地/远程双栏文件浏览器 - 文件上传/下载(带进度) - 调试控制台:实时

一、效果展示

二、源码分享

1、工程结构

2、库安装

3、main.py

"""
功能:
  - 连接/断开 ftp 服务器
  - 本地/远程双栏文件浏览器
  - 文件上传/下载(带进度)
  - 调试控制台:实时 ftp 命令/响应日志 + 手动发送原始命令
"""

import os
import sys
import threading
import time
from datetime import datetime
from ftplib import ftp, error_perm

from pyqt6.qtcore import (
    qt, qthread, pyqtsignal, qobject, qsettings
)
from pyqt6.qtgui import (
    qfont, qcolor, qpalette
)
from pyqt6.qtwidgets import (
    qapplication, qmainwindow, qwidget, qvboxlayout, qhboxlayout,
    qgridlayout, qgroupbox, qlabel, qlineedit, qpushbutton,
    qtreewidget, qtreewidgetitem, qheaderview, qtextedit,
    qsplitter, qprogressbar, qfiledialog, qmessagebox,
    qstatusbar, qtoolbar, qmenu, qabstractitemview, qcombobox
)


# ============================================================
#  调试 ftp 客户端 - 拦截所有命令和响应
# ============================================================
class debugftp(ftp):
    """继承 ftplib.ftp,重写 putline/getline 以捕获所有协议交互"""

    def __init__(self, *args, log_callback=none, **kwargs):
        self._log_callback = log_callback
        super().__init__(*args, **kwargs)

    def putline(self, line):
        if self._log_callback:
            self._log_callback("cmd", line)
        super().putline(line)

    def getline(self):
        line = super().getline()
        if self._log_callback:
            self._log_callback("resp", line)
        return line


# ============================================================
#  ftp 信号定义
# ============================================================
class ftpsignals(qobject):
    log_message = pyqtsignal(str, str)           # (类别, 消息) 类别: cmd/resp/info/error/success
    file_list_received = pyqtsignal(list, str)    # (文件列表, 当前路径)
    transfer_progress = pyqtsignal(str, int, int) # (文件名, 已传输, 总大小)
    transfer_complete = pyqtsignal(str, bool, str) # (文件名, 成功, 消息)
    connection_changed = pyqtsignal(bool)         # 连接状态
    raw_command_result = pyqtsignal(str)          # 原始命令执行结果


# ============================================================
#  ftp 工作线程
# ============================================================
class ftpworkerthread(qthread):
    """在后台线程执行所有 ftp 操作"""

    def __init__(self, parent=none):
        super().__init__(parent)
        self.signals = ftpsignals()
        self.ftp = none
        self._connected = false
        self._stop_flag = false
        self._task_queue = []
        self._lock = threading.lock()

    def connect_to_server(self, host, port, user, password):
        """添加连接任务"""
        self._enqueue("connect", {
            "host": host, "port": port,
            "user": user, "password": password
        })

    def disconnect_from_server(self):
        """添加断开连接任务"""
        self._enqueue("disconnect", {})

    def list_directory(self, path=""):
        """添加列目录任务"""
        self._enqueue("list", {"path": path})

    def change_directory(self, path):
        """添加切换目录任务"""
        self._enqueue("cwd", {"path": path})

    def upload_file(self, local_path, remote_name):
        """添加上传任务"""
        self._enqueue("upload", {
            "local_path": local_path, "remote_name": remote_name
        })

    def download_file(self, remote_name, local_path):
        """添加下载任务"""
        self._enqueue("download", {
            "remote_name": remote_name, "local_path": local_path
        })

    def execute_raw_command(self, command):
        """添加原始命令执行任务"""
        self._enqueue("raw_cmd", {"command": command})

    def delete_file(self, name):
        """添加删除文件任务"""
        self._enqueue("delete_file", {"name": name})

    def delete_directory(self, name):
        """添加删除目录任务"""
        self._enqueue("delete_dir", {"name": name})

    def _enqueue(self, task_type, params):
        with self._lock:
            self._task_queue.append((task_type, params))

    def run(self):
        while not self._stop_flag:
            task = none
            with self._lock:
                if self._task_queue:
                    task = self._task_queue.pop(0)

            if task is none:
                self.msleep(50)
                continue

            task_type, params = task
            try:
                if task_type == "connect":
                    self._do_connect(**params)
                elif task_type == "disconnect":
                    self._do_disconnect()
                elif task_type == "list":
                    self._do_list(**params)
                elif task_type == "cwd":
                    self._do_cwd(**params)
                elif task_type == "upload":
                    self._do_upload(**params)
                elif task_type == "download":
                    self._do_download(**params)
                elif task_type == "raw_cmd":
                    self._do_raw_command(**params)
                elif task_type == "delete_file":
                    self._do_delete_file(**params)
                elif task_type == "delete_dir":
                    self._do_delete_dir(**params)
            except exception as e:
                self.signals.log_message.emit("error", f"操作失败: {str(e)}")

    def stop(self):
        self._stop_flag = true
        if self.ftp and self._connected:
            try:
                self.ftp.quit()
            except exception:
                pass
        self.wait(3000)

    def _do_connect(self, host, port, user, password):
        self.signals.log_message.emit("info", f"正在连接 {host}:{port} ...")
        try:
            self.ftp = debugftp(log_callback=self._ftp_log)
            self.ftp.connect(host, int(port), timeout=10)
            self.ftp.login(user, password)
            self._connected = true
            self.signals.log_message.emit("success", f"已连接到 {host}:{port}")
            self.signals.connection_changed.emit(true)
            # 连接成功后列出根目录
            self._do_list(path="")
        except exception as e:
            self.signals.log_message.emit("error", f"连接失败: {str(e)}")
            self.signals.connection_changed.emit(false)
            self._connected = false

    def _do_disconnect(self):
        try:
            if self.ftp:
                self.ftp.quit()
        except exception:
            pass
        self._connected = false
        self.ftp = none
        self.signals.log_message.emit("info", "已断开连接")
        self.signals.connection_changed.emit(false)

    def _do_list(self, path=""):
        if not self._connected or not self.ftp:
            return

        try:
            if path:
                self.ftp.cwd(path)

            cwd = self.ftp.pwd()
            self.signals.log_message.emit("info", f"当前目录: {cwd}")

            items = []
            try:
                # 尝试 mlsd(结构化目录列表)
                for name, facts in self.ftp.mlsd():
                    if name == ".":
                        continue
                    if name == "..":
                        items.append(("..", 0, true, ""))
                        continue
                    is_dir = facts.get("type", "") == "dir"
                    size = int(facts.get("size", 0))
                    modify = facts.get("modify", "")
                    items.append((name, size, is_dir, modify))
            except error_perm:
                # 回退到 list 命令解析
                lines = []
                self.ftp.retrlines("list", lines.append)
                for line in lines:
                    parsed = self._parse_list_line(line)
                    if parsed:
                        items.append(parsed)

            self.signals.file_list_received.emit(items, cwd)

        except exception as e:
            self.signals.log_message.emit("error", f"列目录失败: {str(e)}")

    def _do_cwd(self, path):
        if not self._connected or not self.ftp:
            return
        try:
            self.ftp.cwd(path)
            self._do_list()
        except exception as e:
            self.signals.log_message.emit("error", f"切换目录失败: {str(e)}")

    def _do_upload(self, local_path, remote_name):
        if not self._connected or not self.ftp:
            return
        filename = os.path.basename(local_path)
        file_size = os.path.getsize(local_path)
        self.signals.log_message.emit("info", f"开始上传: {filename} ({self._format_size(file_size)})")

        transferred = [0]
        last_emit = [time.time()]

        class trackingfile:
            """包装文件对象,在每次 read 时跟踪传输进度"""
            def __init__(self, fobj):
                self._f = fobj
            def read(self, *args, **kwargs):
                data = self._f.read(*args, **kwargs)
                if data:
                    transferred[0] += len(data)
                    now = time.time()
                    if now - last_emit[0] > 0.1:
                        self.signals.transfer_progress.emit(
                            filename, transferred[0], file_size
                        )
                        last_emit[0] = now
                return data
            def __getattr__(self, name):
                return getattr(self._f, name)

        try:
            with open(local_path, "rb") as f:
                self.ftp.storbinary(f"stor {remote_name}", trackingfile(f))

            self.signals.transfer_progress.emit(filename, file_size, file_size)
            self.signals.transfer_complete.emit(filename, true, "上传成功")
            self.signals.log_message.emit("success", f"上传完成: {filename}")
        except exception as e:
            self.signals.transfer_complete.emit(filename, false, str(e))
            self.signals.log_message.emit("error", f"上传失败: {filename} - {str(e)}")

    def _do_download(self, remote_name, local_path):
        if not self._connected or not self.ftp:
            return
        filename = os.path.basename(remote_name)
        self.signals.log_message.emit("info", f"开始下载: {filename}")

        # 获取文件大小
        file_size = 0
        try:
            file_size = self.ftp.size(remote_name) or 0
        except exception:
            pass

        transferred = [0]
        last_emit = [time.time()]

        def on_data(data):
            f.write(data)
            transferred[0] += len(data)
            now = time.time()
            if now - last_emit[0] > 0.1:
                self.signals.transfer_progress.emit(filename, transferred[0], file_size)
                last_emit[0] = now

        try:
            with open(local_path, "wb") as f:
                self.ftp.retrbinary(f"retr {remote_name}", on_data)

            if file_size > 0:
                self.signals.transfer_progress.emit(filename, file_size, file_size)
            self.signals.transfer_complete.emit(filename, true, "下载成功")
            self.signals.log_message.emit("success", f"下载完成: {filename}")
        except exception as e:
            self.signals.transfer_complete.emit(filename, false, str(e))
            self.signals.log_message.emit("error", f"下载失败: {filename} - {str(e)}")

    def _do_raw_command(self, command):
        if not self._connected or not self.ftp:
            self.signals.raw_command_result.emit("错误: 未连接到服务器")
            return

        try:
            cmd = command.strip()
            if not cmd:
                return

            parts = cmd.split(none, 1)
            cmd_name = parts[0].upper()
            cmd_args = parts[1] if len(parts) > 1 else ""

            # 特殊处理一些常见命令
            if cmd_name == "list":
                lines = []
                if cmd_args:
                    self.ftp.retrlines(f"list {cmd_args}", lines.append)
                else:
                    self.ftp.retrlines("list", lines.append)
                result = "\n".join(lines) if lines else "(空目录)"
            elif cmd_name == "nlst":
                lines = []
                self.ftp.retrlines(f"nlst {cmd_args}" if cmd_args else "nlst", lines.append)
                result = "\n".join(lines) if lines else "(空)"
            elif cmd_name == "pwd":
                result = self.ftp.pwd()
            elif cmd_name == "cwd":
                self.ftp.cwd(cmd_args)
                result = f"当前目录: {self.ftp.pwd()}"
            elif cmd_name == "size":
                size = self.ftp.size(cmd_args)
                result = f"{cmd_args}: {size} bytes" if size is not none else "无法获取大小"
            elif cmd_name == "mkd":
                result = self.ftp.mkd(cmd_args)
            elif cmd_name == "rmd":
                self.ftp.rmd(cmd_args)
                result = f"已删除目录: {cmd_args}"
            elif cmd_name == "dele":
                self.ftp.delete(cmd_args)
                result = f"已删除文件: {cmd_args}"
            elif cmd_name == "ren":
                # ren old new
                ren_parts = cmd_args.split(none, 1)
                if len(ren_parts) == 2:
                    self.ftp.rename(ren_parts[0], ren_parts[1])
                    result = f"已重命名: {ren_parts[0]} -> {ren_parts[1]}"
                else:
                    result = "用法: ren <旧名> <新名>"
            elif cmd_name == "help":
                resp = self.ftp.sendcmd("help")
                result = resp
            elif cmd_name in ("quit", "bye"):
                result = "请使用界面上的断开按钮"
            else:
                # 通用命令发送
                resp = self.ftp.sendcmd(cmd)
                result = resp

            self.signals.raw_command_result.emit(result)

        except exception as e:
            self.signals.raw_command_result.emit(f"错误: {str(e)}")

    def _do_delete_file(self, name):
        """删除远程文件"""
        if not self._connected or not self.ftp:
            return
        try:
            self.ftp.delete(name)
            self.signals.log_message.emit("success", f"已删除文件: {name}")
            # 删除后刷新目录
            self._do_list()
        except exception as e:
            self.signals.log_message.emit("error", f"删除文件失败: {name} - {str(e)}")

    def _do_delete_dir(self, name):
        """删除远程目录"""
        if not self._connected or not self.ftp:
            return
        try:
            self.ftp.rmd(name)
            self.signals.log_message.emit("success", f"已删除目录: {name}")
            # 删除后刷新目录
            self._do_list()
        except exception as e:
            self.signals.log_message.emit("error", f"删除目录失败: {name} - {str(e)}")

    def _ftp_log(self, category, message):
        """ftp 命令/响应日志回调"""
        self.signals.log_message.emit(category, message)

    def _parse_list_line(self, line):
        """解析 list 命令输出的每一行"""
        # unix 格式: drwxr-xr-x  2 user group  4096 jan 01 12:00 filename
        # dos 格式:  01-01-24  12:00am  <dir>  dirname
        #            01-01-24  12:00am       1234 filename
        parts = line.split(none, 8)

        if len(parts) >= 9 and (parts[0][0] in ('d', '-', 'l', 'r')):
            # unix 格式
            is_dir = parts[0][0] == 'd'
            try:
                size = int(parts[4])
            except (valueerror, indexerror):
                size = 0
            name = parts[8]
            return (name, size, is_dir, "")

        elif len(parts) >= 4:
            # 尝试 dos 格式
            try:
                is_dir = parts[2] == '<dir>'
                size = 0 if is_dir else int(parts[2])
                name = parts[3]
                return (name, size, is_dir, "")
            except (valueerror, indexerror):
                pass

        return none

    @staticmethod
    def _format_size(size_bytes):
        if size_bytes < 1024:
            return f"{size_bytes} b"
        elif size_bytes < 1024 * 1024:
            return f"{size_bytes / 1024:.1f} kb"
        elif size_bytes < 1024 * 1024 * 1024:
            return f"{size_bytes / (1024 * 1024):.1f} mb"
        else:
            return f"{size_bytes / (1024 * 1024 * 1024):.2f} gb"


# ============================================================
#  文件浏览器 widget
# ============================================================
class filebrowserwidget(qwidget):
    """文件浏览器组件,用于远程和本地文件浏览"""

    file_selected = pyqtsignal(str, bool)  # (文件路径, 是否为目录)
    file_delete_requested = pyqtsignal(str, bool)  # (名称, 是否为目录)

    def __init__(self, title="文件浏览器", is_remote=false, parent=none):
        super().__init__(parent)
        self.is_remote = is_remote
        self.current_path = ""
        self.path_history = []

        layout = qvboxlayout(self)
        layout.setcontentsmargins(4, 4, 4, 4)
        layout.setspacing(4)

        # 标题和路径
        header = qhboxlayout()
        self.title_label = qlabel(f"<b>{title}</b>")
        header.addwidget(self.title_label)
        header.addstretch()

        self.path_label = qlineedit()
        self.path_label.setreadonly(true)
        self.path_label.setplaceholdertext("路径...")
        self.path_label.setminimumwidth(200)
        header.addwidget(self.path_label, stretch=1)

        layout.addlayout(header)

        # 文件列表
        self.tree = qtreewidget()
        self.tree.setheaderlabels(["名称", "大小", "类型"])
        self.tree.setcolumncount(3)
        self.tree.setrootisdecorated(false)
        self.tree.setsortingenabled(true)
        self.tree.setselectionmode(qabstractitemview.selectionmode.singleselection)
        self.tree.setcontextmenupolicy(qt.contextmenupolicy.customcontextmenu)
        self.tree.customcontextmenurequested.connect(self._show_context_menu)
        self.tree.itemdoubleclicked.connect(self._on_item_double_clicked)

        header_view = self.tree.header()
        header_view.setstretchlastsection(true)
        header_view.setsectionresizemode(0, qheaderview.resizemode.stretch)
        header_view.setsectionresizemode(1, qheaderview.resizemode.resizetocontents)
        header_view.setsectionresizemode(2, qheaderview.resizemode.resizetocontents)

        layout.addwidget(self.tree)

    def set_file_list(self, items, current_path=""):
        """设置文件列表 items: [(name, size, is_dir, modify), ...]"""
        self.tree.clear()
        self.current_path = current_path
        self.path_label.settext(current_path)

        # 排序:目录在前,文件在后
        dirs = sorted([i for i in items if i[2]], key=lambda x: x[0].lower())
        files = sorted([i for i in items if not i[2]], key=lambda x: x[0].lower())

        for name, size, is_dir, modify in dirs + files:
            item = qtreewidgetitem()
            icon = "📁" if is_dir else "📄"
            if name == "..":
                icon = "⬆️"

            item.settext(0, f"{icon} {name}")
            item.settext(1, self._format_size(size) if not is_dir else "")
            item.settext(2, "文件夹" if is_dir else "文件")
            item.setdata(0, qt.itemdatarole.userrole, {
                "name": name, "size": size, "is_dir": is_dir
            })
            self.tree.addtoplevelitem(item)

    def _on_item_double_clicked(self, item, column):
        data = item.data(0, qt.itemdatarole.userrole)
        if data:
            self.file_selected.emit(data["name"], data["is_dir"])

    def _show_context_menu(self, pos):
        item = self.tree.itemat(pos)
        if not item:
            return
        data = item.data(0, qt.itemdatarole.userrole)
        if not data:
            return

        menu = qmenu(self)
        if data["is_dir"]:
            action = menu.addaction("打开目录")
            action.triggered.connect(
                lambda: self.file_selected.emit(data["name"], true)
            )
            if self.is_remote and data["name"] != "..":
                menu.addseparator()
                del_action = menu.addaction("🗑️ 删除目录")
                del_action.triggered.connect(
                    lambda: self.file_delete_requested.emit(data["name"], true)
                )
        else:
            if self.is_remote:
                action = menu.addaction("下载")
                action.triggered.connect(
                    lambda: self.file_selected.emit(data["name"], false)
                )
                menu.addseparator()
                del_action = menu.addaction("删除文件")
                del_action.triggered.connect(
                    lambda: self.file_delete_requested.emit(data["name"], false)
                )
            else:
                action = menu.addaction("上传")
                action.triggered.connect(
                    lambda: self.file_selected.emit(data["name"], false)
                )
        menu.exec(self.tree.viewport().maptoglobal(pos))

    def load_local_directory(self, path):
        """加载本地目录内容"""
        if not os.path.isdir(path):
            return
        items = []
        try:
            if path != os.path.dirname(path):  # 非根目录,添加 ..
                items.append(("..", 0, true, ""))
            for name in os.listdir(path):
                full_path = os.path.join(path, name)
                is_dir = os.path.isdir(full_path)
                try:
                    size = os.path.getsize(full_path) if not is_dir else 0
                except oserror:
                    size = 0
                items.append((name, size, is_dir, ""))
            self.set_file_list(items, path)
        except permissionerror:
            pass

    @staticmethod
    def _format_size(size_bytes):
        if size_bytes < 1024:
            return f"{size_bytes} b"
        elif size_bytes < 1024 * 1024:
            return f"{size_bytes / 1024:.1f} kb"
        elif size_bytes < 1024 * 1024 * 1024:
            return f"{size_bytes / (1024 * 1024):.1f} mb"
        else:
            return f"{size_bytes / (1024 * 1024 * 1024):.2f} gb"


# ============================================================
#  传输进度 widget
# ============================================================
class transferwidget(qwidget):
    def __init__(self, parent=none):
        super().__init__(parent)
        layout = qvboxlayout(self)
        layout.setcontentsmargins(4, 4, 4, 4)

        header = qlabel("<b>传输队列</b>")
        layout.addwidget(header)

        self.transfer_list = qtreewidget()
        self.transfer_list.setheaderlabels(["文件", "方向", "进度", "状态"])
        self.transfer_list.setcolumncount(4)
        self.transfer_list.setrootisdecorated(false)

        header_view = self.transfer_list.header()
        header_view.setstretchlastsection(true)
        header_view.setsectionresizemode(0, qheaderview.resizemode.stretch)
        header_view.setsectionresizemode(1, qheaderview.resizemode.resizetocontents)
        header_view.setsectionresizemode(3, qheaderview.resizemode.resizetocontents)

        layout.addwidget(self.transfer_list)
        self._progress_map = {}

    def add_transfer(self, filename, direction):
        """direction: 'upload' or 'download'"""
        item = qtreewidgetitem()
        icon = "📤" if direction == "upload" else "📥"
        item.settext(0, filename)
        item.settext(1, icon)
        item.settext(2, "0%")
        item.settext(3, "等待中")
        self.transfer_list.addtoplevelitem(item)

        progress = qprogressbar()
        progress.setmaximum(100)
        progress.setvalue(0)
        self.transfer_list.setitemwidget(item, 2, progress)
        self._progress_map[filename] = (item, progress)

    def update_progress(self, filename, transferred, total):
        if filename in self._progress_map:
            item, progress = self._progress_map[filename]
            if total > 0:
                pct = int(transferred * 100 / total)
                progress.setvalue(pct)
                item.settext(2, f"{pct}%")
            item.settext(3, "传输中...")

    def complete_transfer(self, filename, success, message):
        if filename in self._progress_map:
            item, progress = self._progress_map[filename]
            progress.setvalue(100 if success else 0)
            item.settext(2, "完成" if success else "失败")
            item.settext(3, message)
            if success:
                item.setforeground(3, qcolor("#2ecc71"))
            else:
                item.setforeground(3, qcolor("#e74c3c"))

    def clear_completed(self):
        items_to_remove = []
        for i in range(self.transfer_list.toplevelitemcount()):
            item = self.transfer_list.toplevelitem(i)
            status = item.text(3)
            if status in ("上传成功", "下载成功", "上传失败", "下载失败"):
                items_to_remove.append(i)
        for i in reversed(items_to_remove):
            self.transfer_list.taketoplevelitem(i)


# ============================================================
#  调试控制台 widget
# ============================================================
class debugconsolewidget(qwidget):
    command_submitted = pyqtsignal(str)  # 用户输入的原始命令

    def __init__(self, parent=none):
        super().__init__(parent)
        layout = qvboxlayout(self)
        layout.setcontentsmargins(4, 4, 4, 4)

        # 标题行
        header = qhboxlayout()
        title = qlabel("<b>调试控制台</b>")
        header.addwidget(title)
        header.addstretch()

        clear_btn = qpushbutton("清空")
        clear_btn.setfixedwidth(60)
        clear_btn.clicked.connect(self.clear_log)
        header.addwidget(clear_btn)

        save_btn = qpushbutton("保存日志")
        save_btn.setfixedwidth(80)
        save_btn.clicked.connect(self.save_log)
        header.addwidget(save_btn)

        layout.addlayout(header)

        # 日志显示区
        self.log_text = qtextedit()
        self.log_text.setreadonly(true)
        self.log_text.setfont(qfont("consolas", 9))
        layout.addwidget(self.log_text)

        # 命令输入行
        cmd_layout = qhboxlayout()
        cmd_label = qlabel("ftp 命令:")
        cmd_layout.addwidget(cmd_label)

        self.cmd_input = qlineedit()
        self.cmd_input.setplaceholdertext("输入 ftp 命令,如: list /, size file.txt, mkd newdir, pwd ...")
        self.cmd_input.setfont(qfont("consolas", 10))
        self.cmd_input.returnpressed.connect(self._on_send_command)
        cmd_layout.addwidget(self.cmd_input)

        send_btn = qpushbutton("发送")
        send_btn.setfixedwidth(60)
        send_btn.clicked.connect(self._on_send_command)
        cmd_layout.addwidget(send_btn)

        layout.addlayout(cmd_layout)

        # 命令历史
        self._cmd_history = []
        self._history_idx = -1

    def append_log(self, category, message):
        """添加日志消息"""
        timestamp = datetime.now().strftime("%h:%m:%s")

        color_map = {
            "cmd": "#e67e22",     # 橙色 - 发送的命令
            "resp": "#3498db",    # 蓝色 - 服务器响应
            "info": "#95a5a6",    # 灰色 - 信息
            "error": "#e74c3c",   # 红色 - 错误
            "success": "#2ecc71", # 绿色 - 成功
        }

        prefix_map = {
            "cmd": ">>>",
            "resp": "<<<",
            "info": " ℹ️",
            "error": " ❌",
            "success": " ✅",
        }

        color = color_map.get(category, "#333333")
        prefix = prefix_map.get(category, "   ")

        html = f'<span style="color:#888;">[{timestamp}]</span> '
        html += f'<span style="color:{color};font-weight:bold;">{prefix}</span> '
        html += f'<span style="color:{color};">{self._escape_html(message)}</span>'

        self.log_text.append(html)
        # 自动滚动到底部
        scrollbar = self.log_text.verticalscrollbar()
        scrollbar.setvalue(scrollbar.maximum())

    def clear_log(self):
        self.log_text.clear()

    def save_log(self):
        path, _ = qfiledialog.getsavefilename(
            self, "保存日志", "ftp_debug_log.txt",
            "文本文件 (*.txt);;所有文件 (*)"
        )
        if path:
            with open(path, "w", encoding="utf-8") as f:
                f.write(self.log_text.toplaintext())

    def _on_send_command(self):
        cmd = self.cmd_input.text().strip()
        if cmd:
            self._cmd_history.insert(0, cmd)
            self._history_idx = -1
            self.command_submitted.emit(cmd)
            self.cmd_input.clear()

    def keypressevent(self, event):
        if event.key() == qt.key.key_up:
            if self._cmd_history:
                self._history_idx = min(
                    self._history_idx + 1, len(self._cmd_history) - 1
                )
                self.cmd_input.settext(self._cmd_history[self._history_idx])
        elif event.key() == qt.key.key_down:
            if self._cmd_history and self._history_idx > 0:
                self._history_idx -= 1
                self.cmd_input.settext(self._cmd_history[self._history_idx])
            elif self._history_idx == 0:
                self._history_idx = -1
                self.cmd_input.clear()
        else:
            super().keypressevent(event)

    @staticmethod
    def _escape_html(text):
        return text.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")


# ============================================================
#  主窗口
# ============================================================
class mainwindow(qmainwindow):
    def __init__(self):
        super().__init__()
        self.setwindowtitle("ftp 客户端调试助手")
        self.setminimumsize(1100, 750)

        # ftp 工作线程
        self.ftp_worker = ftpworkerthread()
        self.ftp_worker.signals.log_message.connect(self._on_log_message)
        self.ftp_worker.signals.file_list_received.connect(self._on_file_list_received)
        self.ftp_worker.signals.transfer_progress.connect(self._on_transfer_progress)
        self.ftp_worker.signals.transfer_complete.connect(self._on_transfer_complete)
        self.ftp_worker.signals.connection_changed.connect(self._on_connection_changed)
        self.ftp_worker.signals.raw_command_result.connect(self._on_raw_command_result)
        self.ftp_worker.start()

        self._init_ui()
        self._load_settings()

    def _init_ui(self):
        central = qwidget()
        self.setcentralwidget(central)
        main_layout = qvboxlayout(central)
        main_layout.setcontentsmargins(8, 8, 8, 8)
        main_layout.setspacing(6)

        # ---- 1. 连接管理 + 服务器控制 ----
        conn_group = qgroupbox("连接管理")
        conn_layout = qgridlayout(conn_group)
        conn_layout.setcontentsmargins(8, 12, 8, 8)
        conn_layout.sethorizontalspacing(8)
        conn_layout.setverticalspacing(6)

        # 状态指示灯
        self.status_indicator = qlabel("●")
        self.status_indicator.setstylesheet("color: #e74c3c; font-size: 18px;")
        self.status_indicator.settooltip("未连接")
        conn_layout.addwidget(self.status_indicator, 0, 0)

        # 主机
        conn_layout.addwidget(qlabel("主机:"), 0, 1)
        self.host_input = qlineedit("127.0.0.1")
        self.host_input.setfixedwidth(150)
        conn_layout.addwidget(self.host_input, 0, 2)

        # 端口
        conn_layout.addwidget(qlabel("端口:"), 0, 3)
        self.port_input = qlineedit("2121")
        self.port_input.setfixedwidth(70)
        conn_layout.addwidget(self.port_input, 0, 4)

        # 用户名
        conn_layout.addwidget(qlabel("用户名:"), 0, 5)
        self.user_input = qlineedit("admin")
        self.user_input.setfixedwidth(100)
        conn_layout.addwidget(self.user_input, 0, 6)

        # 密码
        conn_layout.addwidget(qlabel("密码:"), 0, 7)
        self.pass_input = qlineedit("123456")
        self.pass_input.setechomode(qlineedit.echomode.password)
        self.pass_input.setfixedwidth(100)
        conn_layout.addwidget(self.pass_input, 0, 8)

        # 连接/断开按钮
        self.connect_btn = qpushbutton("连接")
        self.connect_btn.setfixedwidth(70)
        self.connect_btn.setstylesheet(
            "qpushbutton { background-color: #2ecc71; color: white; "
            "font-weight: bold; border-radius: 4px; padding: 4px; }"
            "qpushbutton:hover { background-color: #27ae60; }"
        )
        self.connect_btn.clicked.connect(self._on_connect)
        conn_layout.addwidget(self.connect_btn, 0, 9)

        self.disconnect_btn = qpushbutton("断开")
        self.disconnect_btn.setfixedwidth(70)
        self.disconnect_btn.setenabled(false)
        self.disconnect_btn.setstylesheet(
            "qpushbutton { background-color: #e74c3c; color: white; "
            "font-weight: bold; border-radius: 4px; padding: 4px; }"
            "qpushbutton:hover { background-color: #c0392b; }"
            "qpushbutton:disabled { background-color: #bdc3c7; }"
        )
        self.disconnect_btn.clicked.connect(self._on_disconnect)
        conn_layout.addwidget(self.disconnect_btn, 0, 10)
        conn_layout.setcolumnstretch(11, 1)
        main_layout.addwidget(conn_group)

        # ---- 2. 文件浏览器(双栏) ----
        file_splitter = qsplitter(qt.orientation.horizontal)

        # 本地文件浏览器(左侧)
        self.local_browser = filebrowserwidget("本地文件", is_remote=false)
        self.local_browser.file_selected.connect(self._on_local_file_action)
        self.local_browser.load_local_directory(os.path.expanduser("~"))

        local_btn_layout = qhboxlayout()
        browse_btn = qpushbutton("📂 浏览...")
        browse_btn.clicked.connect(self._browse_local)
        local_btn_layout.addwidget(browse_btn)

        refresh_local_btn = qpushbutton("🔄 刷新")
        refresh_local_btn.clicked.connect(
            lambda: self.local_browser.load_local_directory(
                self.local_browser.current_path
            )
        )
        local_btn_layout.addwidget(refresh_local_btn)
        local_btn_layout.addstretch()

        local_container = qwidget()
        local_container_layout = qvboxlayout(local_container)
        local_container_layout.setcontentsmargins(0, 0, 0, 0)
        local_container_layout.setspacing(2)
        local_container_layout.addwidget(self.local_browser)
        local_container_layout.addlayout(local_btn_layout)

        file_splitter.addwidget(local_container)

        # 远程文件浏览器(右侧)
        self.remote_browser = filebrowserwidget("远程文件", is_remote=true)
        self.remote_browser.file_selected.connect(self._on_remote_file_action)
        self.remote_browser.file_delete_requested.connect(
            self._on_remote_delete_request
        )

        # 上一级 + 刷新按钮
        remote_btn_layout = qhboxlayout()
        up_remote_btn = qpushbutton("⬆️ 上一级")
        up_remote_btn.clicked.connect(self._go_up_remote)
        remote_btn_layout.addwidget(up_remote_btn)

        refresh_remote_btn = qpushbutton("🔄 刷新")
        refresh_remote_btn.clicked.connect(self._refresh_remote)
        remote_btn_layout.addwidget(refresh_remote_btn)
        remote_btn_layout.addstretch()

        remote_container = qwidget()
        remote_container_layout = qvboxlayout(remote_container)
        remote_container_layout.setcontentsmargins(0, 0, 0, 0)
        remote_container_layout.setspacing(2)
        remote_container_layout.addwidget(self.remote_browser)
        remote_container_layout.addlayout(remote_btn_layout)

        file_splitter.addwidget(remote_container)

        file_splitter.setstretchfactor(0, 1)
        file_splitter.setstretchfactor(1, 1)

        main_layout.addwidget(file_splitter, stretch=3)

        # ---- 3. 传输队列 ----
        self.transfer_widget = transferwidget()
        main_layout.addwidget(self.transfer_widget, stretch=1)

        # ---- 4. 调试控制台 ----
        self.debug_console = debugconsolewidget()
        # 将调试控制台的命令提交信号连接到工作线程
        self.debug_console.command_submitted.connect(
            self.ftp_worker.execute_raw_command
        )
        main_layout.addwidget(self.debug_console, stretch=2)

        # ---- 状态栏 ----
        self.statusbar().showmessage("就绪")

    # ---- 连接操作 ----
    def _on_connect(self):
        host = self.host_input.text().strip()
        port = self.port_input.text().strip()
        user = self.user_input.text().strip()
        password = self.pass_input.text().strip()

        if not host or not port:
            qmessagebox.warning(self, "提示", "请输入主机地址和端口")
            return

        self.connect_btn.setenabled(false)
        self.statusbar().showmessage(f"正在连接 {host}:{port}...")
        self.ftp_worker.connect_to_server(host, port, user, password)

    def _on_disconnect(self):
        self.ftp_worker.disconnect_from_server()
        self.remote_browser.tree.clear()
        self.remote_browser.path_label.clear()

    def _on_connection_changed(self, connected):
        self.connect_btn.setenabled(not connected)
        self.disconnect_btn.setenabled(connected)

        if connected:
            self.status_indicator.setstylesheet(
                "color: #2ecc71; font-size: 18px;"
            )
            self.status_indicator.settooltip("已连接")
            self.statusbar().showmessage("已连接")
        else:
            self.status_indicator.setstylesheet(
                "color: #e74c3c; font-size: 18px;"
            )
            self.status_indicator.settooltip("未连接")
            self.statusbar().showmessage("已断开")

    # ---- 文件浏览 ----
    def _on_file_list_received(self, items, current_path):
        self.remote_browser.set_file_list(items, current_path)
        self.statusbar().showmessage(
            f"远程目录: {current_path} ({len(items)} 个项目)"
        )

    def _on_remote_file_action(self, name, is_dir):
        if is_dir:
            if name == "..":
                self.ftp_worker.change_directory("..")
            else:
                self.ftp_worker.change_directory(name)
        else:
            # 文件被选中 — 弹出下载对话框
            self._prompt_download(name)

    def _on_remote_delete_request(self, name, is_dir):
        kind = "目录" if is_dir else "文件"
        reply = qmessagebox.question(
            self, "确认删除",
            f"确定要删除远程{kind}吗?\n\n名称: {name}\n\n"
            "注意: 此操作不可恢复。",
            qmessagebox.standardbutton.yes | qmessagebox.standardbutton.no
        )
        if reply == qmessagebox.standardbutton.yes:
            if is_dir:
                self.ftp_worker.delete_directory(name)
            else:
                self.ftp_worker.delete_file(name)

    def _on_local_file_action(self, name, is_dir):
        if is_dir:
            if name == "..":
                parent = os.path.dirname(self.local_browser.current_path)
                self.local_browser.load_local_directory(parent)
            else:
                new_path = os.path.join(self.local_browser.current_path, name)
                self.local_browser.load_local_directory(new_path)
        else:
            # 本地文件被选中 — 弹出上传对话框
            full_path = os.path.join(self.local_browser.current_path, name)
            self._prompt_upload(full_path)

    def _go_up_remote(self):
        self.ftp_worker.change_directory("..")

    def _refresh_remote(self):
        self.ftp_worker.list_directory()

    def _browse_local(self):
        directory = qfiledialog.getexistingdirectory(
            self, "选择本地目录", self.local_browser.current_path
        )
        if directory:
            self.local_browser.load_local_directory(directory)

    # ---- 文件传输 ----
    def _prompt_download(self, remote_name):
        save_path, _ = qfiledialog.getsavefilename(
            self, "下载文件", os.path.join(os.path.expanduser("~"), remote_name)
        )
        if save_path:
            self.transfer_widget.add_transfer(remote_name, "download")
            self.ftp_worker.download_file(remote_name, save_path)

    def _prompt_upload(self, local_path):
        remote_name = os.path.basename(local_path)
        reply = qmessagebox.question(
            self, "确认上传",
            f"上传文件到远程当前目录?\n\n文件: {remote_name}",
            qmessagebox.standardbutton.yes | qmessagebox.standardbutton.no
        )
        if reply == qmessagebox.standardbutton.yes:
            self.transfer_widget.add_transfer(remote_name, "upload")
            self.ftp_worker.upload_file(local_path, remote_name)

    def _on_transfer_progress(self, filename, transferred, total):
        self.transfer_widget.update_progress(filename, transferred, total)

    def _on_transfer_complete(self, filename, success, message):
        self.transfer_widget.complete_transfer(filename, success, message)
        # 传输完成后刷新远程目录
        if success:
            self.ftp_worker.list_directory()

    # ---- 日志 ----
    def _on_log_message(self, category, message):
        self.debug_console.append_log(category, message)
        if category == "info":
            self.statusbar().showmessage(message)

    def _on_raw_command_result(self, result):
        self.debug_console.append_log("resp", f"[命令结果] {result}")

    # ---- 设置保存/加载 ----
    def _load_settings(self):
        settings = qsettings("ftpdebugassistant", "ftphelper")
        host = settings.value("host", "127.0.0.1")
        port = settings.value("port", "2121")
        user = settings.value("user", "admin")
        self.host_input.settext(host)
        self.port_input.settext(str(port))
        self.user_input.settext(user)

    def _save_settings(self):
        settings = qsettings("ftpdebugassistant", "ftphelper")
        settings.setvalue("host", self.host_input.text())
        settings.setvalue("port", self.port_input.text())
        settings.setvalue("user", self.user_input.text())

    def closeevent(self, event):
        self._save_settings()
        self.ftp_worker.stop()
        event.accept()


# ============================================================
#  入口
# ============================================================
if __name__ == "__main__":
    app = qapplication(sys.argv)
    app.setstyle("fusion")

    # 设置调色板
    palette = qpalette()
    palette.setcolor(qpalette.colorrole.window, qcolor(245, 245, 245))
    palette.setcolor(qpalette.colorrole.windowtext, qcolor(33, 33, 33))
    palette.setcolor(qpalette.colorrole.base, qcolor(255, 255, 255))
    palette.setcolor(qpalette.colorrole.alternatebase, qcolor(240, 240, 240))
    palette.setcolor(qpalette.colorrole.tooltipbase, qcolor(255, 255, 255))
    palette.setcolor(qpalette.colorrole.tooltiptext, qcolor(33, 33, 33))
    palette.setcolor(qpalette.colorrole.text, qcolor(33, 33, 33))
    palette.setcolor(qpalette.colorrole.button, qcolor(240, 240, 240))
    palette.setcolor(qpalette.colorrole.buttontext, qcolor(33, 33, 33))
    palette.setcolor(qpalette.colorrole.highlight, qcolor(52, 152, 219))
    palette.setcolor(qpalette.colorrole.highlightedtext, qcolor(255, 255, 255))
    app.setpalette(palette)

    # 全局样式
    app.setstylesheet("""
        qgroupbox {
            font-weight: bold;
            border: 1px solid #ddd;
            border-radius: 6px;
            margin-top: 10px;
            padding-top: 14px;
        }
        qgroupbox::title {
            subcontrol-origin: margin;
            left: 10px;
            padding: 0 5px;
        }
        qlineedit {
            border: 1px solid #ccc;
            border-radius: 4px;
            padding: 3px 6px;
            background: white;
        }
        qlineedit:focus {
            border-color: #3498db;
        }
        qpushbutton {
            border: 1px solid #ccc;
            border-radius: 4px;
            padding: 4px 12px;
            background: #f0f0f0;
        }
        qpushbutton:hover {
            background: #e0e0e0;
        }
        qpushbutton:pressed {
            background: #d0d0d0;
        }
        qtreewidget {
            border: 1px solid #ddd;
            border-radius: 4px;
            background: white;
            alternate-background-color: #f8f8f8;
        }
        qtreewidget::item:selected {
            background-color: #3498db;
            color: white;
        }
        qheaderview::section {
            background-color: #ecf0f1;
            padding: 4px;
            border: 1px solid #ddd;
            font-weight: bold;
        }
        qtextedit {
            border: 1px solid #ddd;
            border-radius: 4px;
            background: #1e1e1e;
            color: #d4d4d4;
        }
        qprogressbar {
            border: 1px solid #ddd;
            border-radius: 4px;
            text-align: center;
        }
        qprogressbar::chunk {
            background-color: #3498db;
            border-radius: 3px;
        }
    """)

    window = mainwindow()
    window.show()
    sys.exit(app.exec())




4、ftp_server_test.py(测试使用)

"""
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()


三、实现原理

本工具的核心设计思路是:在 ftplib.ftp 的基础上做一层“协议级”的拦截与封装,配合 qt 的信号槽机制,把耗时的网络操作放到后台线程执行,从而既保证界面流畅,又能把每一次 ftp 命令与响应实时呈现到调试控制台。 下面从几个关键点展开说明。

1、协议拦截:继承 ftp 重写 putline / getline

ftp 协议本质上是基于 tcp 的文本协议,客户端与服务器之间通过 ascii 命令和响应进行交互。ftplib.ftp 内部把“发送命令”和“读取响应”分别封装在 putline() 与 getline() 两个方法中。

因此,只要继承 ftp 并重写这两个方法,就能在不改动任何业务逻辑的前提下,捕获到客户端发出的每一条命令和服务器返回的每一条响应:

class debugftp(ftp):
    def putline(self, line):
        if self._log_callback:
            self._log_callback("cmd", line)   # 拦截发出的命令
        super().putline(line)

    def getline(self):
        line = super().getline()
        if self._log_callback:
            self._log_callback("resp", line)  # 拦截收到的响应
        return line
  • putline 在命令真正写入 socket 之前被调用,此时 line 就是即将发送的原始命令文本(如 user admin、pwd、stor test.txt)。
  • getline 在从 socket 读取到一行响应后被调用,此时 line 就是服务器返回的原始响应(如 230 login successful.、257 "/" is the current directory.)。
  • 通过构造函数传入的 log_callback 回调,把 ("cmd", line) 或 ("resp", line) 交给上层,再由信号 log_message 转发到调试控制台。

这样做的最大好处是零侵入:ftplib 内部所有命令(login、cwd、storbinary、retrbinary、mlsd 等)最终都会走这两个方法,因此日志覆盖是完整且自动的,无需为每个命令单独埋点。

2、线程模型:qthread + 任务队列

ftp 网络操作(连接、列目录、上传、下载)都是阻塞调用,如果直接放在 gui 主线程执行,界面会卡死。因此本项目把 ftpworkerthread 作为唯一的 ftp 操作执行者,运行在后台线程中。

2.1 任务队列

工作线程内部维护一个线程安全的任务队列:

self._task_queue = []
self._lock = threading.lock()

def _enqueue(self, task_type, params):
    with self._lock:
        self._task_queue.append((task_type, params))

主线程(界面)通过 connect_to_server()、upload_file() 等方法向队列追加任务,工作线程在 run() 中循环取出并执行:

def run(self):
    while not self._stop_flag:
        task = none
        with self._lock:
            if self._task_queue:
                task = self._task_queue.pop(0)
        if task is none:
            self.msleep(50)
            continue
        task_type, params = task
        try:
            # 根据 task_type 分发到对应的 _do_xxx 方法
            ...
        except exception as e:
            self.signals.log_message.emit("error", f"操作失败: {str(e)}")
  • 队列为空时线程 msleep(50) 让出 cpu,避免空转。
  • 所有任务在同一个后台线程中串行执行,天然避免了多个 ftp 操作并发导致的协议状态错乱(ftp 是有状态协议,同一连接上并发操作会互相干扰)。
  • 每个任务用 try/except 包裹,任何异常都不会导致线程退出,而是通过信号上报给界面。

2.2 线程安全

  • 任务入队使用 threading.lock 保护,保证主线程与工作线程对队列的并发访问安全。
  • 工作线程不直接操作任何界面控件,只通过 ftpsignals 信号把结果发回主线程;主线程中的槽函数(如 _on_file_list_received)负责更新界面。这符合 qt 的线程规则:只有主线程可以操作 gui 控件。

3、信号槽通信:ftpsignals

ftpsignals 继承自 qobject,集中定义了工作线程与主线程之间的所有通信信号:

class ftpsignals(qobject):
    log_message = pyqtsignal(str, str)            # (类别, 消息)
    file_list_received = pyqtsignal(list, str)    # (文件列表, 当前路径)
    transfer_progress = pyqtsignal(str, int, int) # (文件名, 已传输, 总大小)
    transfer_complete = pyqtsignal(str, bool, str) # (文件名, 成功, 消息)
    connection_changed = pyqtsignal(bool)         # 连接状态
    raw_command_result = pyqtsignal(str)          # 原始命令执行结果
  • log_message:携带类别(cmd / resp / info / error / success)和消息文本,调试控制台据此用不同颜色渲染。
  • file_list_received:携带解析后的文件列表和当前路径,远程文件浏览器据此刷新。
  • transfer_progress / transfer_complete:驱动传输队列的进度条和完成状态。
  • connection_changed:通知主线程更新连接按钮的可用状态和状态指示灯。

由于 pyqtsignal 是跨线程安全的,工作线程 emit 的信号会自动排队到主线程的事件循环中执行对应的槽函数,从而实现了线程间安全通信。

4、目录列表解析:mlsd 优先,list 兜底

ftp 列目录有两种常见方式:

  • mlsd:返回结构化数据(类型、大小、修改时间等),解析简单可靠,但需要服务器支持(rfc 3659)。
  • list:返回人类可读的文本列表,格式因服务器而异(unix 风格、dos 风格等),解析较繁琐。

本工具的策略是优先尝试 mlsd,失败则回退到 list:

try:
    for name, facts in self.ftp.mlsd():
        is_dir = facts.get("type", "") == "dir"
        size = int(facts.get("size", 0))
        modify = facts.get("modify", "")
        items.append((name, size, is_dir, modify))
except error_perm:
    lines = []
    self.ftp.retrlines("list", lines.append)
    for line in lines:
        parsed = self._parse_list_line(line)
        if parsed:
            items.append(parsed)

_parse_list_line 同时兼容 unix 和 dos 两种常见格式:

  • unix 格式:drwxr-xr-x 2 user group 4096 jan 01 12:00 filename,通过权限位首字符判断类型,取第 5 个字段为大小,第 9 个字段为文件名。
  • dos 格式:01-01-24 12:00am <dir> dirname,通过 <dir> 标记判断目录,否则取数字字段为大小。

5、上传/下载进度跟踪

5.1 上传:包装文件对象

上传时通过 storbinary 逐块读取本地文件并发送。为了跟踪进度,把文件对象包装一层,在每次 read() 时累计已读字节数:

class trackingfile:
    def read(self, *args, **kwargs):
        data = self._f.read(*args, **kwargs)
        if data:
            transferred[0] += len(data)
            now = time.time()
            if now - last_emit[0] > 0.1:
                self.signals.transfer_progress.emit(filename, transferred[0], file_size)
                last_emit[0] = now
        return data
  • 用 transferred[0] 列表而非普通变量,是为了在闭包中修改外部变量。
  • 通过 time.time() 做节流:每 100ms 才发一次进度信号,避免高频信号刷爆界面。

5.2 下载:回调函数

下载时通过 retrbinary 的 callback 参数接收数据块,同样累计字节数并节流发送进度:

def on_data(data):
    f.write(data)
    transferred[0] += len(data)
    now = time.time()
    if now - last_emit[0] > 0.1:
        self.signals.transfer_progress.emit(filename, transferred[0], file_size)
        last_emit[0] = now

下载前先通过 ftp.size() 获取文件总大小,用于计算百分比;若获取失败则进度条显示为不确定状态。

6、原始命令执行

调试控制台允许用户手动输入任意 ftp 命令。_do_raw_command 对常见命令做了专门处理:

  • list / nlst:通过 retrlines 获取多行结果。
  • pwd / cwd / size / mkd / rmd / dele / ren:调用 ftplib 对应方法。
  • 其他命令:通过 sendcmd 原样发送并返回服务器响应。
else:
    resp = self.ftp.sendcmd(cmd)
    result = resp

这样既覆盖了常用操作,又保留了任意命令透传的能力,方便调试非标准或扩展的 ftp 命令。

7、小结

设计点实现方式解决的问题
协议拦截继承 ftp 重写 putline / getline零侵入捕获全部命令与响应
后台执行qthread + 任务队列避免网络阻塞卡死界面
线程通信pyqtsignal 信号槽跨线程安全更新界面
目录解析mlsd 优先 + list 兜底兼容不同服务器
进度跟踪包装文件对象 / 回调 + 节流实时且不刷爆界面
原始命令sendcmd 透传支持任意 ftp 命令调试

通过以上设计,本工具既是一个可用的 ftp 客户端,更是一个协议级调试器——所有交互过程完全透明可见,非常适合学习 ftp 协议或排查服务器问题。

以上就是python基于pyqt实现ftp客户端调试助手的详细内容,更多关于python pyqt实现ftp客户端调试助手的资料请关注代码网其它相关文章!

赞 (0)

相关文章:

版权声明:本文内容由互联网用户贡献,该文观点仅代表作者本人。本站仅提供信息存储服务,不拥有所有权,不承担相关法律责任。 如发现本站有涉嫌抄袭侵权/违法违规的内容, 请发送邮件至 2386932994@qq.com 举报,一经查实将立刻删除。

发表评论

验证码:
Copyright © 2017-2026  代码网 保留所有权利. 粤ICP备2024248653号
站长QQ:2386932994 | 联系邮箱:2386932994@qq.com