python --基于scp的文件传输助手

python 复制代码
import os
import sys
import time
import queue
import threading
import tkinter as tk
from tkinter import filedialog, messagebox
from tkinter import font as tkfont

# =====================================================================
# 依赖检查
# =====================================================================
try:
    import paramiko
except ImportError:
    tk.Tk().withdraw()
    messagebox.showerror("缺少依赖", "请先安装 paramiko 库:\n\npip install paramiko")
    sys.exit(1)

# =====================================================================
# DPI 感知(必须在 Tk() 之前)
# =====================================================================
try:
    from ctypes import windll

    try:
        windll.shcore.SetProcessDpiAwareness(1)
    except Exception:
        try:
            windll.user32.SetProcessDPIAware()
        except Exception:
            pass
except Exception:
    pass

# =====================================================================
# 全局配置(改这里)
# =====================================================================
SERVER_HOST = "xx.xx.xx.xx"
SERVER_PORT = 22
SERVER_USER = "root"
SERVER_PASS = "xxxxxxxx"

# 发送:以 "/" 结尾视为目录(自动拼本地文件名),否则视为完整文件路径
# 接收:必须是完整文件路径,且扩展名必须是 .zip
SERVER_FILE = "/root/1.zip"

LOGO_FILE = "logo.png"

# === 限制发送文件大小 ===
MAX_FILE_SIZE = 10 * 1024 * 1024 * 1024  # 10GB
# =====================================================================

# ============================ iOS 配色 ==============================
BG = "#F2F2F7"
CARD = "#FFFFFF"
BLUE = "#007AFF"
GRAY = "#E5E5EA"
TEXT = "#1C1C1E"
HINT = "#8E8E93"
# =====================================================================

FONT = "Segoe UI"
WINDOW_W, WINDOW_H = 700, 500
ALLOWED_EXT = ".zip"


# ---------------------------------------------------------------------
# 工具
# ---------------------------------------------------------------------
def pick_font_family():
    try:
        fams = set(tkfont.families())
        for f in ("Microsoft YaHei UI", "Microsoft YaHei",
                  "PingFang SC", "Segoe UI"):
            if f in fams:
                return f
    except Exception:
        pass
    return "Microsoft YaHei"


def app_dir():
    if getattr(sys, "frozen", False):
        return os.path.dirname(sys.executable)
    return os.path.dirname(os.path.abspath(__file__))


def format_size(n):
    if n is None:
        return "-"
    n = float(n)
    for unit in ("B", "KB", "MB", "GB", "TB"):
        if n < 1024 or unit == "TB":
            return f"{int(n)} B" if unit == "B" else f"{n:.2f} {unit}"
        n /= 1024


def is_zip(path):
    return bool(path) and path.lower().endswith(ALLOWED_EXT)


def resolve_upload_target(local_file):
    remote = (SERVER_FILE or "").strip()
    if not remote:
        return os.path.basename(local_file)
    if remote.endswith("/"):
        return remote + os.path.basename(local_file)
    return remote


def round_rect_points(x1, y1, x2, y2, r):
    return [
        x1 + r, y1, x2 - r, y1, x2, y1, x2, y1 + r,
        x2, y2 - r, x2, y2, x2 - r, y2, x1 + r, y2,
        x1, y2, x1, y2 - r, x1, y1 + r, x1, y1,
    ]


# ---------------------------------------------------------------------
# iOS 风格控件
# ---------------------------------------------------------------------
class RoundedCard(tk.Canvas):
    def __init__(self, master, radius=14, bg=CARD, outer=BG, pad=18, **kw):
        super().__init__(master, bg=outer, highlightthickness=0, height=100, **kw)
        self._radius = radius
        self._bg = bg
        self._pad = pad
        self.body = tk.Frame(self, bg=bg)
        self._content_win = self.create_window(pad, pad, window=self.body, anchor="nw")
        self.bind("<Configure>", self._on_self)
        self.body.bind("<Configure>", self._on_body)
        self.after(0, self._on_body)

    def _on_body(self, _=None):
        need = self.body.winfo_reqheight() + 2 * self._pad
        if abs(self.winfo_height() - need) > 1:
            self.configure(height=need)

    def _on_self(self, _=None):
        w = self.winfo_width()
        h = self.winfo_height()
        if w <= 1 or h <= 1:
            return
        self.coords(self._content_win, self._pad, self._pad)
        self.itemconfigure(self._content_win, width=max(1, w - 2 * self._pad))
        self.delete("bg")
        pts = round_rect_points(0, 0, w, h, self._radius)
        self.create_polygon(pts, smooth=True, fill=self._bg, outline="", tags="bg")
        self.tag_lower("bg")


class RoundedEntry(tk.Canvas):
    def __init__(self, master, textvariable=None, height=40, radius=10,
                 bg=BG, fg=TEXT, outer=CARD, **kw):
        super().__init__(master, height=height, bg=outer,
                         highlightthickness=0, **kw)
        self._entry_h = height
        self._radius = radius
        self._bg = bg
        self.entry = tk.Entry(self, textvariable=textvariable, bd=0,
                              bg=bg, fg=fg, insertbackground=fg,
                              font=(FONT, 11))
        self._content_win = self.create_window(14, height // 2,
                                               window=self.entry, anchor="w")
        self.bind("<Configure>", self._redraw)
        self.bind("<Button-1>", lambda e: self.entry.focus_set())

    def _redraw(self, _=None):
        w = self.winfo_width()
        h = self._entry_h
        if w <= 1:
            return
        self.delete("bg")
        pts = round_rect_points(0, 0, w, h, self._radius)
        self.create_polygon(pts, smooth=True, fill=self._bg, outline="", tags="bg")
        self.tag_lower("bg")
        self.coords(self._content_win, 14, h // 2)
        self.itemconfigure(self._content_win, width=max(1, w - 28))


class RoundedButton(tk.Canvas):
    def __init__(self, master, text, command=None, bg=BLUE, fg="white",
                 width=110, height=38, radius=10, font_size=11, bold=True,
                 outer=None, **kw):
        outer = outer or master["bg"]
        super().__init__(master, width=width, height=height, bg=outer,
                         highlightthickness=0, **kw)
        self._btn_w = width
        self._btn_h = height
        self._radius = radius
        self._bg = bg
        self._fg = fg
        self._text = text
        self._command = command
        self._font_size = font_size
        self._bold = bold
        self._enabled = True
        self._hover = False
        self._draw()
        self.bind("<Button-1>", self._click)
        self.bind("<Enter>", self._enter)
        self.bind("<Leave>", self._leave)

    def _draw(self):
        self.delete("all")
        bg, fg = self._bg, self._fg
        if not self._enabled:
            bg, fg = "#C7C7CC", "#FFFFFF"
        elif self._hover:
            bg = self._shade(self._bg, 0.92)
        pts = round_rect_points(0, 0, self._btn_w, self._btn_h, self._radius)
        self.create_polygon(pts, smooth=True, fill=bg, outline="")
        style = "bold" if self._bold else "normal"
        self.create_text(self._btn_w / 2, self._btn_h / 2, text=self._text,
                         fill=fg, font=(FONT, self._font_size, style))

    @staticmethod
    def _shade(hex_color, factor):
        h = hex_color.lstrip("#")
        r, g, b = (int(h[i:i + 2], 16) for i in (0, 2, 4))
        return f"#{int(r * factor):02x}{int(g * factor):02x}{int(b * factor):02x}"

    def _click(self, _=None):
        if self._enabled and self._command:
            self._command()

    def _enter(self, _=None):
        if self._enabled:
            self._hover = True
            self._draw()

    def _leave(self, _=None):
        self._hover = False
        self._draw()

    def set_enabled(self, enabled):
        self._enabled = enabled
        self._hover = False
        self._draw()


class RoundedProgress(tk.Canvas):
    def __init__(self, master, height=10, radius=5,
                 track=GRAY, fill=BLUE, outer=CARD, **kw):
        super().__init__(master, height=height, bg=outer,
                         highlightthickness=0, **kw)
        self._bar_h = height
        self._radius = radius
        self._track = track
        self._fill = fill
        self._value = 0.0
        self.bind("<Configure>", self._redraw)

    def set(self, value):
        self._value = max(0.0, min(100.0, float(value)))
        self._redraw()

    def _redraw(self, _=None):
        w = self.winfo_width()
        h = self._bar_h
        if w <= 1:
            return
        self.delete("all")
        self.create_polygon(round_rect_points(0, 0, w, h, self._radius),
                            smooth=True, fill=self._track, outline="")
        fw = w * self._value / 100.0
        if fw > 1:
            r = min(self._radius, fw / 2)
            self.create_polygon(round_rect_points(0, 0, fw, h, r),
                                smooth=True, fill=self._fill, outline="")


# ---------------------------------------------------------------------
# 主应用
# ---------------------------------------------------------------------
class App:
    def __init__(self, root):
        global FONT
        FONT = pick_font_family()

        self.root = root
        self.root.title("文件传输助手")
        self.root.resizable(False, False)
        self.root.configure(bg=BG)

        self._apply_dpi_scaling()
        self._icon_img = None
        self._set_icon()

        self.queue = queue.Queue()
        self.busy = False
        self.current_page = None
        self._last_update = 0.0

        # 共用状态变量
        self.var_status = tk.StringVar(value="就绪")
        self.var_send_local = tk.StringVar()
        self.var_recv_folder = tk.StringVar()

        self.container = tk.Frame(root, bg=BG)
        self.container.pack(fill="both", expand=True)

        self.page_home = self._build_home()
        self.page_send = self._build_send()
        self.page_recv = self._build_recv()

        self._center_window()
        self.show_page("home")
        self.root.after(100, self._poll_queue)

    # ---------- DPI / 图标 / 居中 ----------
    def _apply_dpi_scaling(self):
        try:
            dpi = self.root.winfo_fpixels('1i')
            self.root.tk.call('tk', 'scaling', dpi / 72.0)
        except Exception:
            pass

    def _set_icon(self):
        for p in (os.path.join(app_dir(), LOGO_FILE),
                  os.path.join(os.getcwd(), LOGO_FILE)):
            if os.path.isfile(p):
                try:
                    self._icon_img = tk.PhotoImage(file=p)
                    self.root.iconphoto(True, self._icon_img)
                    return
                except Exception:
                    pass

    def _center_window(self):
        self.root.update_idletasks()
        sw = self.root.winfo_screenwidth()
        sh = self.root.winfo_screenheight()
        x = max(0, (sw - WINDOW_W) // 2)
        y = max(0, (sh - WINDOW_H) // 2)
        self.root.geometry(f"{WINDOW_W}x{WINDOW_H}+{x}+{y}")

    # ---------- 页面切换 ----------
    def show_page(self, name):
        for p in (self.page_home, self.page_send, self.page_recv):
            p.pack_forget()
        if not self.busy:
            self.var_status.set("就绪")
            self.progress_send.set(0)
            self.progress_recv.set(0)
        if name == "home":
            self.page_home.pack(fill="both", expand=True)
        elif name == "send":
            self.page_send.pack(fill="both", expand=True)
        elif name == "recv":
            self.page_recv.pack(fill="both", expand=True)
        self.current_page = name

    # ---------- 首页 ----------
    def _build_home(self):
        f = tk.Frame(self.container, bg=BG)
        tk.Label(f, text="文件传输助手", bg=BG, fg=TEXT,
                 font=(FONT, 26, "bold")).pack(pady=(50, 4))
        tk.Label(f, text="请选择操作", bg=BG, fg=HINT,
                 font=(FONT, 12)).pack(pady=(0, 40))

        btn_frame = tk.Frame(f, bg=BG)
        btn_frame.pack()

        RoundedButton(btn_frame, "发送  ↑",
                      command=lambda: self.show_page("send"),
                      bg=BLUE, fg="white", width=240, height=78,
                      radius=18, font_size=18, outer=BG).pack(pady=10)

        RoundedButton(btn_frame, "接收  ↓",
                      command=lambda: self.show_page("recv"),
                      bg=BLUE, fg="white", width=240, height=78,
                      radius=18, font_size=18, outer=BG).pack(pady=10)
        return f

    # ---------- 页头 ----------
    def _make_header(self, parent, title, back_cmd):
        header = tk.Frame(parent, bg=BG)
        header.pack(fill="x", padx=20, pady=(18, 6))

        back_btn = RoundedButton(header, "←  返回", command=back_cmd,
                                 bg=GRAY, fg=TEXT, width=90, height=36,
                                 radius=10, font_size=11, bold=False, outer=BG)
        back_btn.pack(side="left")

        tk.Label(header, text=title, bg=BG, fg=TEXT,
                 font=(FONT, 20, "bold")).pack(side="left", padx=16)
        return back_btn

    # ---------- 进度卡片 ----------
    def _make_progress_card(self, parent):
        card = RoundedCard(parent)
        card.pack(fill="x", padx=20, pady=8)
        tk.Label(card.body, text="传输进度", bg=CARD, fg=HINT,
                 font=(FONT, 10, "bold")).pack(anchor="w", pady=(0, 8))
        progress = RoundedProgress(card.body, outer=CARD)
        progress.pack(fill="x")
        tk.Label(card.body, textvariable=self.var_status, bg=CARD, fg=HINT,
                 font=(FONT, 10)).pack(anchor="w", pady=(8, 0))
        return progress

    # ---------- 发送页 ----------
    def _build_send(self):
        f = tk.Frame(self.container, bg=BG)
        self.btn_back_send = self._make_header(f, "发送文件", lambda: self.show_page("home"))

        card = RoundedCard(f)
        card.pack(fill="x", padx=20, pady=6)
        tk.Label(card.body, text="本地文件(仅 .zip)", bg=CARD, fg=HINT,
                 font=(FONT, 10, "bold")).pack(anchor="w", pady=(0, 6))
        RoundedEntry(card.body, textvariable=self.var_send_local,
                     outer=CARD).pack(fill="x")
        RoundedButton(card.body, "选择文件", command=self._browse_send,
                      bg=BLUE, width=110, height=36,
                      outer=CARD).pack(anchor="e", pady=(10, 0))

        self.progress_send = self._make_progress_card(f)

        btn_bar = tk.Frame(f, bg=BG)
        btn_bar.pack(pady=(18, 20))
        self.btn_send = RoundedButton(
            btn_bar, "发送", command=self.start_upload,
            bg=BLUE, fg="white", width=200, height=50,
            radius=14, font_size=14)
        self.btn_send.pack()
        return f

    # ---------- 接收页 ----------
    def _build_recv(self):
        f = tk.Frame(self.container, bg=BG)
        self.btn_back_recv = self._make_header(f, "接收文件", lambda: self.show_page("home"))

        card = RoundedCard(f)
        card.pack(fill="x", padx=20, pady=6)
        tk.Label(card.body, text="保存文件夹", bg=CARD, fg=HINT,
                 font=(FONT, 10, "bold")).pack(anchor="w", pady=(0, 6))
        RoundedEntry(card.body, textvariable=self.var_recv_folder,
                     outer=CARD).pack(fill="x")
        RoundedButton(card.body, "选择文件夹", command=self._browse_recv,
                      bg=BLUE, width=120, height=36,
                      outer=CARD).pack(anchor="e", pady=(10, 0))

        self.progress_recv = self._make_progress_card(f)

        btn_bar = tk.Frame(f, bg=BG)
        btn_bar.pack(pady=(18, 20))
        self.btn_recv = RoundedButton(
            btn_bar, "接收", command=self.start_download,
            bg=BLUE, fg="white", width=200, height=50,
            radius=14, font_size=14)
        self.btn_recv.pack()
        return f

    # ---------- 文件/文件夹选择 ----------
    def _browse_send(self):
        p = filedialog.askopenfilename(
            title="选择要发送的 ZIP 文件",
            filetypes=[("ZIP 文件", "*.zip")])
        if not p:
            return
        if not is_zip(p):
            messagebox.showwarning("提示", f"仅支持 {ALLOWED_EXT} 文件")
            return
        if not os.path.isfile(p):
            messagebox.showwarning("提示", "文件不存在")
            return

        size = os.path.getsize(p)
        if size > MAX_FILE_SIZE:
            messagebox.showwarning("提示", f"文件过大!\n最大支持发送 10GB 的文件,当前大小:{format_size(size)}")
            return

        self.var_send_local.set(p)

    def _browse_recv(self):
        p = filedialog.askdirectory(title="选择保存文件夹")
        if p:
            self.var_recv_folder.set(p)

    # ---------- 发送 ----------
    def start_upload(self):
        local = self.var_send_local.get().strip()
        if not local:
            messagebox.showwarning("提示", "请选择本地 ZIP 文件")
            return
        if not is_zip(local):
            messagebox.showwarning("提示", f"仅支持 {ALLOWED_EXT} 文件")
            return
        if not os.path.isfile(local):
            messagebox.showwarning("提示", "本地文件不存在")
            return

        size = os.path.getsize(local)
        if size > MAX_FILE_SIZE:
            messagebox.showwarning("提示", f"文件过大!\n最大支持发送 10GB 的文件,当前大小:{format_size(size)}")
            return

        self._launch("upload", local)

    # ---------- 接收 ----------
    def start_download(self):
        folder = self.var_recv_folder.get().strip()
        if not folder:
            messagebox.showwarning("提示", "请选择保存文件夹")
            return
        if not os.path.isdir(folder):
            messagebox.showwarning("提示", "保存文件夹不存在")
            return
        remote = (SERVER_FILE or "").strip()
        if not remote:
            messagebox.showwarning("提示", "SERVER_FILE 未配置")
            return
        fname = os.path.basename(remote.rstrip("/\\"))
        if not fname or not is_zip(fname):
            messagebox.showwarning("提示", "服务器文件不是 .zip 格式,无法接收")
            return

        save_path = os.path.join(folder, fname)

        # 断点续传判断:如果已有同名文件,询问用户
        if os.path.exists(save_path):
            ans = messagebox.askyesnocancel(
                "发现同名文件",
                "本地文件夹已存在同名文件。\n\n"
                "是(Yes):断点续传(从断点继续)\n"
                "否(No):覆盖重新下载\n"
                "取消(Cancel):放弃操作"
            )
            if ans is None:  # 取消
                return
            elif ans is False:  # 覆盖重下
                try:
                    os.remove(save_path)
                except Exception as e:
                    messagebox.showerror("提示", f"无法删除旧文件,可能被占用:\n{e}")
                    return

        self._launch("download", save_path)

    # ---------- 启动 ----------
    def _launch(self, mode, local_or_save):
        if self.busy:
            return
        self.busy = True
        self.progress_send.set(0)
        self.progress_recv.set(0)
        self.var_status.set("正在连接...")
        self._set_buttons(True)
        threading.Thread(target=self._worker,
                         args=(mode, local_or_save),
                         daemon=True).start()

    def _set_buttons(self, busy):
        self.btn_send.set_enabled(not busy)
        self.btn_back_send.set_enabled(not busy)
        self.btn_recv.set_enabled(not busy)
        self.btn_back_recv.set_enabled(not busy)

    # ---------- 后台传输线程 ----------
    def _worker(self, mode, arg):
        client = None
        try:
            client = paramiko.SSHClient()
            client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
            client.connect(hostname=SERVER_HOST, port=SERVER_PORT,
                           username=SERVER_USER, password=SERVER_PASS,
                           timeout=10)
            sftp = client.open_sftp()

            def progress_cb(transferred, total):
                now = time.time()
                if now - self._last_update >= 0.1 or (total and transferred >= total):
                    self._last_update = now
                    pct = int(transferred * 100.0 / total) if total else 0
                    self.queue.put(("progress", (pct, transferred, total)))

            if mode == "upload":
                remote = resolve_upload_target(arg)
                total = os.path.getsize(arg)
                self.queue.put(("set_total", total))
                # 上传不使用断点,直接使用原生 put 覆盖
                sftp.put(arg, remote, callback=progress_cb, confirm=True)
            else:
                remote = SERVER_FILE.strip()
                total = sftp.stat(remote).st_size
                self.queue.put(("set_total", total))
                # 下载使用手写的断点续传逻辑
                self._download_with_resume(sftp, remote, arg, progress_cb)

            sftp.close()
            self.queue.put(("done", None))

        except Exception as e:
            self.queue.put(("error", str(e)))
        finally:
            if client:
                try:
                    client.close()
                except Exception:
                    pass

    # ---------- 仅下载的断点续传核心逻辑 ----------
    def _download_with_resume(self, sftp, remote, local, callback):
        local_size = os.path.getsize(local) if os.path.exists(local) else 0
        remote_size = sftp.stat(remote).st_size

        # 如果本地比服务器还大,或者大小一样,直接截断或跳过
        if local_size > remote_size:
            local_size = 0
            try:
                os.remove(local)
            except Exception:
                pass

        if local_size == remote_size:
            callback(local_size, remote_size)
            return

        # r+b 追加模式,或者 wb 覆盖模式
        mode = 'r+b' if local_size > 0 else 'wb'
        with sftp.open(remote, 'rb') as f_remote:
            f_remote.seek(local_size)
            with open(local, mode) as f_local:
                if local_size > 0:
                    f_local.seek(local_size)
                else:
                    f_local.truncate()

                transferred = local_size
                callback(transferred, remote_size)
                chunk_size = 32768  # 32KB 分块

                while True:
                    data = f_remote.read(chunk_size)
                    if not data:
                        break
                    f_local.write(data)
                    transferred += len(data)
                    callback(transferred, remote_size)

    # ---------- UI 轮询 ----------
    def _poll_queue(self):
        try:
            while True:
                kind, payload = self.queue.get_nowait()

                if kind == "progress":
                    pct, transferred, total = payload
                    self.progress_send.set(pct)
                    self.progress_recv.set(pct)
                    if total:
                        self.var_status.set(
                            f"{pct:3d}%    {format_size(transferred)} / {format_size(total)}")
                    else:
                        self.var_status.set(f"{pct:3d}%    {format_size(transferred)}")

                elif kind == "set_total":
                    self.var_status.set(f"  0%    0 B / {format_size(payload)}")

                elif kind == "done":
                    self.progress_send.set(100)
                    self.progress_recv.set(100)
                    self.var_status.set("传输完成")
                    self.busy = False
                    self._set_buttons(False)

                elif kind == "error":
                    self.var_status.set("传输失败")
                    self.busy = False
                    self._set_buttons(False)
                    messagebox.showerror("传输错误", payload)

        except queue.Empty:
            pass
        self.root.after(100, self._poll_queue)


if __name__ == "__main__":
    root = tk.Tk()
    App(root)
    root.mainloop()
相关推荐
一条破秋裤1 小时前
SDIO Wi‑Fi 设备的驱动向insmod + 反向rmmod
开发语言·php
m0_639906941 小时前
Java 重写 Override 与重载 Overload 区别、访问修饰符、static 关键字
java·开发语言
Evand J1 小时前
【MATLAB例程】平面下,5基站对待定位点的定位。基于距离辅助下的AOA。完整代码下载链接
开发语言·matlab·平面·定位·导航·测距·aoa
用户0332126663671 小时前
使用 Python 在 Word 文档中添加、修改和删除变量
python
GIS数据转换器1 小时前
基于CIM的城市智慧供热监管服务平台
java·大数据·开发语言·人工智能·后端·物联网·struts
专注仿真1 小时前
动态规划算法详解
人工智能·python·大模型训练·控制算法
weixin_417396832 小时前
星级酒店境外电视频道怎么合法接入?合规要点与核对清单
开发语言·前端·php·1024程序员节·综合布线
熊猫钓鱼>_>2 小时前
鸿蒙PC构建实战:hvigor modelVersion 降级 + schema 清理 + ArkTS 编译三连坑完整复盘
开发语言·华为·ai编程·harmonyos·openharmony·数据·ai生成
全栈陈序员2 小时前
【Python】基础语法入门(二十五)—— 网络编程入门:用 socket 与 requests 联网
python