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